Skip to content
Open
149 changes: 149 additions & 0 deletions e2e/connect_watcher.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
#!/usr/bin/env bats

# Focused regressions for the shipped request-watching script, using the
# existing BATS suite. No Basecamp server or credentials are used.
setup() {
WATCHER="${WATCHER_UNDER_TEST:-$BATS_TEST_DIRNAME/../skills/basecamp-connect/watch-requests.sh}"
WORK="$(mktemp -d)"
OUT="$WORK/connector.log"
HANDLED="$WORK/handled"
: > "$OUT"
}

teardown() {
if [ -n "${WATCH_PID:-}" ]; then
kill "$WATCH_PID" 2>/dev/null || true
wait "$WATCH_PID" 2>/dev/null || true
fi
chmod -R u+rwX "$WORK"
rm -rf "$WORK"
}

@test "watcher reports usage without its required paths" {
run bash "$WATCHER"
[ "$status" -eq 2 ]
[[ "$output" == *usage:* ]]
}

@test "watcher refuses a missing connector output" {
run bash "$WATCHER" --once "$WORK/missing" "$HANDLED"
[ "$status" -eq 2 ]
[[ "$output" == *"no connector output"* ]]
}

@test "watcher can drain an empty output" {
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 0 ]
[ -z "$output" ]
}

@test "watcher creates the handled directory and returns a fresh request" {
printf '%s\n' '{"type":"request","event_id":101}' > "$OUT"
run bash "$WATCHER" --once "$OUT" "$WORK/nested/handled"
[ "$status" -eq 0 ]
[ "$output" = '{"type":"request","event_id":101}' ]
[ -f "$WORK/nested/handled" ]
}

@test "watcher skips ids already handled" {
printf '%s\n' '{"type":"request","event_id":101}' > "$OUT"
printf '%s\n' 101 > "$HANDLED"
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 0 ]
[ -z "$output" ]
}

@test "watcher matches handled ids exactly rather than by prefix" {
printf '%s\n' '{"type":"request","event_id":101}' '{"type":"request","event_id":1011}' > "$OUT"
printf '%s\n' 101 > "$HANDLED"
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 0 ]
[ "$output" = '{"type":"request","event_id":1011}' ]
}

@test "watcher emits a duplicated request once" {
printf '%s\n' '{"type":"request","event_id":101}' '{"type":"request","event_id":101}' > "$OUT"
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 0 ]
[ "$output" = '{"type":"request","event_id":101}' ]
}

@test "watcher ignores event and pointer lines" {
printf '%s\n' '{"type":"event","event_id":101}' '{"event_id":102}' '{"type":"request","event_id":103}' > "$OUT"
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 0 ]
[ "$output" = '{"type":"request","event_id":103}' ]
}

@test "watcher does not deliver a partial line until it is complete" {
printf '%s\n' '{"type":"event","event_id":100}' > "$OUT"
printf '%s' '{"type":"request","event_id":101}' >> "$OUT"
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 0 ]
[ -z "$output" ]
printf '\n' >> "$OUT"
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 0 ]
[ "$output" = '{"type":"request","event_id":101}' ]
}

@test "watcher does not mistake an escaped id in content for the request id" {
printf '%s\n' '{"type":"request","content":"quoted \"event_id\":999","event_id":101}' > "$OUT"
printf '%s\n' 999 > "$HANDLED"
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 0 ]
[[ "$output" == *'"event_id":101}' ]]
}

@test "watcher preserves backslashes in the handled path" {
printf '%s\n' '{"type":"request","event_id":101}' > "$OUT"
printf '%s\n' 101 > "$WORK/handled\name"
run bash "$WATCHER" --once "$OUT" "$WORK/handled\name"
[ "$status" -eq 0 ]
[ -z "$output" ]
}

@test "watcher refuses an unusable handled path" {
mkdir "$HANDLED"
run bash "$WATCHER" --once "$OUT" "$HANDLED"
[ "$status" -eq 2 ]
[[ "$output" == *"can't use the handled file"* ]]
}

@test "watcher fails closed if the handled file vanishes after startup" {
mkdir "$WORK/bin"
printf '%s\n' '#!/usr/bin/env bash' 'rm -f "$WATCHER_HANDLED"' 'exec "$REAL_WC" "$@"' > "$WORK/bin/wc"
chmod +x "$WORK/bin/wc"
local real_wc
real_wc="$(command -v wc)"
printf '%s\n' '{"type":"request","event_id":101}' > "$OUT"
for mode in once polling; do
printf '%s\n' 101 > "$HANDLED"
local args=()
[ "$mode" != once ] || args+=(--once)
run env PATH="$WORK/bin:$PATH" WATCHER_HANDLED="$HANDLED" REAL_WC="$real_wc" \
bash "$WATCHER" "${args[@]}" "$OUT" "$HANDLED"
[ "$status" -eq 2 ]
[[ "$output" == *"cannot read the handled file"* ]]
[[ "$output" != *'"event_id":101'* ]]
done
}

@test "watcher wakes when a new complete request arrives" {
bash "$WATCHER" "$OUT" "$HANDLED" > "$WORK/result" 2> "$WORK/errors" &
WATCH_PID=$!
printf '%s\n' '{"type":"request","event_id":101}' >> "$OUT"
for _ in {1..100}; do
[ ! -s "$WORK/result" ] || break
sleep 0.05
done
[ -s "$WORK/result" ]
for _ in {1..100}; do
kill -0 "$WATCH_PID" 2>/dev/null || break
sleep 0.05
done
! kill -0 "$WATCH_PID" 2>/dev/null
wait "$WATCH_PID"
WATCH_PID=
[ "$(< "$WORK/result")" = '{"type":"request","event_id":101}' ]
}
5 changes: 3 additions & 2 deletions internal/commands/agent_hook_connector.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,9 @@ func connectorReloadNotice(input agentHookInput) string {
"finished cut that skill to its first 5,000 tokens, which drops the handoff steps and the subagent's " +
"instructions. If this session is still the connector's orchestrator, read " + skill + " in full with " +
"the Read tool now, before you go on with anything, a request in hand included, then carry on from the " +
"step you were on. The connector, its monitor and any subagents in flight kept running through the " +
"compaction: don't start them again."
"step you were on. The connector and any subagents in flight kept running through the compaction: don't " +
"start them again. The request watcher exits on each request, so check whether it is still running; if it " +
"has exited, read its output and start it again as the skill says."
}

// lastConnectorSkillDir returns the directory of the last basecamp-connect
Expand Down
57 changes: 31 additions & 26 deletions skills/basecamp-connect/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,11 @@ Three pieces:

- **The agent**: a Basecamp person of its own, owned by you, connected to this
computer under a CLI profile (often named after the agent).
- **The connector**: `basecamp connect -P '<profile>'`. It listens to the
account's event feed as the agent, checks each event against who may give the
agent work and which projects it serves, and prints one line per trusted
request. It boosts each one that asks for something first, and does
nothing else.
- **This session**: the orchestrator. It watches those lines, words each
acknowledgement, chooses the repo, and dispatches one background subagent
per request. It never does the work itself.
- **The connector**: `basecamp connect -P '<profile>'`. It checks the agent's
event feed against its trust and served projects, boosts each request that
asks for something, and prints one line per trusted request. Nothing else.
- **This session**: watches those lines, words acknowledgements, chooses the
repo, and dispatches one background subagent per request. Never does the work.

Claude Code only. The connector runs on Linux and macOS.

Expand All @@ -63,25 +60,22 @@ note the output file the harness reports:
basecamp connect -P '<profile>'
```

It runs until stopped, from any folder. After a few seconds its log should say
`connector: running`. If it exited instead, explain the error
(see *When the connector stops*), and stop. If it says `could not get the
agent's token yet`, Basecamp's token endpoint is limiting it or failing: it
retries on its own. Tell the person, leave it running, and arm the monitor;
restarting doesn't help.
It runs from any folder until stopped. Wait a few seconds for
`connector: running`. If it exits, explain why (*When the connector stops*)
and stop. If it logs `could not get the agent's token yet`, tell the person
it will retry; leave it running and start the watcher. Restarting won't help.

Then arm a persistent monitor on that file with the Monitor tool
(`persistent: true`), replaying from the start so nothing that arrived between
the start and the monitor is missed:
Then start the watcher, also with `run_in_background: true`:

```bash
tail -f -n +1 '<connector-output-file>' | grep --line-buffered -F '"type":"request"'
bash '${CLAUDE_SKILL_DIR}/watch-requests.sh' '<connector-output-file>' ~/.config/basecamp-connect/'handled-<profile>'
```

Single-quote the path, as with every other value.

Each notification is one request: handle it with *For each request*. A request
that arrives while you're waiting on the person is not their reply.
It exits with unhandled requests. Read every line from its output file,
record each id (`echo <event_id> >> ~/.config/basecamp-connect/'handled-<profile>'`),
restart the watcher, then handle each with *For each request*. Keep one running.
Use this instead of Monitor: watches expire and `grep -v` can buffer live
lines. A request arriving while you wait on the person is not their reply.

### 2. The request line

Expand Down Expand Up @@ -652,6 +646,16 @@ output and tell the person what it means:

Requests that arrive while it's stopped are not picked up later.

Each start writes a new output file. Stop the watcher (TaskStop), then check
the old file once, so a request printed just before the exit isn't left there:

```bash
bash '${CLAUDE_SKILL_DIR}/watch-requests.sh' --once '<old-output-file>' ~/.config/basecamp-connect/'handled-<profile>'
```

Handle any line it prints as usual. Start the watcher on the new file once the
connector is running.

## Checking on it

- `basecamp connect doctor -P '<profile>'`: the credential, the identity, the
Expand All @@ -675,7 +679,8 @@ Requests that arrive while it's stopped are not picked up later.

## Stopping

When the person asks you to stop, or the session is ending, stop the connector
task (TaskStop, or send it SIGINT). There's nothing else to clean up: no
webhooks, nothing public. Subagents already running finish their requests and
reply. Say that new mentions won't be picked up until it's started again.
When the person asks you to stop, or the session is ending, stop the watcher
and the connector task (TaskStop, or send it SIGINT). There's nothing else to
clean up: no webhooks, nothing public. Subagents already running finish their
requests and reply. Say that new mentions won't be picked up until it's
started again.
61 changes: 61 additions & 0 deletions skills/basecamp-connect/watch-requests.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
#!/usr/bin/env bash
set -u

once=false
if [ "${1:-}" = --once ]; then
once=true
shift
fi
if [ $# -ne 2 ]; then
echo "usage: watch-requests.sh [--once] <connector-output-file> <handled-file>" >&2
exit 2
fi

out=$1
handled=$2

if [ ! -f "$out" ]; then
echo "watch-requests: no connector output at $out" >&2
exit 2
fi
if ! { mkdir -p "$(dirname "$handled")" && touch "$handled" && [ -f "$handled" ] && [ -r "$handled" ] && [ -w "$handled" ]; }; then
echo "watch-requests: can't use the handled file $handled" >&2
exit 2
fi

unhandled() {
Comment thread
robzolkos marked this conversation as resolved.
local lines
if [ ! -r "$out" ]; then
echo "watch-requests: can't read the connector output at $out" >&2
exit 2
fi
lines=$(($(wc -l < "$out")))
[ "$lines" -gt 0 ] || return 1
head -n "$lines" "$out" | HANDLED=$handled awk '
BEGIN {
while ((read = (getline id < ENVIRON["HANDLED"])) > 0) seen[id] = 1
if (read < 0) {
print "watch-requests: cannot read the handled file " ENVIRON["HANDLED"] > "/dev/stderr"
failed = 1
exit 2
}
}
/"type":"request"/ && match($0, /"event_id":[0-9]+/) {
id = substr($0, RSTART + 11, RLENGTH - 11)
if (!(id in seen)) { print; seen[id] = 1; found = 1 }
}
END { if (failed) exit 2; exit !found }'
}

while :; do
unhandled
result=$?
case "$result" in
0) exit 0 ;;
1)
[ "$once" = false ] || exit 0
sleep 2
;;
*) exit "$result" ;;
esac
done
Loading