diff --git a/e2e/connect_watcher.bats b/e2e/connect_watcher.bats new file mode 100644 index 000000000..b49a1ba70 --- /dev/null +++ b/e2e/connect_watcher.bats @@ -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}' ] +} diff --git a/internal/commands/agent_hook_connector.go b/internal/commands/agent_hook_connector.go index 841a0213d..4e5d2b0da 100644 --- a/internal/commands/agent_hook_connector.go +++ b/internal/commands/agent_hook_connector.go @@ -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 diff --git a/skills/basecamp-connect/SKILL.md b/skills/basecamp-connect/SKILL.md index 544337918..3aeecf124 100644 --- a/skills/basecamp-connect/SKILL.md +++ b/skills/basecamp-connect/SKILL.md @@ -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 ''`. 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 ''`. 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. @@ -63,25 +60,22 @@ note the output file the harness reports: basecamp connect -P '' ``` -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 '' | grep --line-buffered -F '"type":"request"' +bash '${CLAUDE_SKILL_DIR}/watch-requests.sh' '' ~/.config/basecamp-connect/'handled-' ``` -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 >> ~/.config/basecamp-connect/'handled-'`), +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 @@ -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 '' ~/.config/basecamp-connect/'handled-' +``` + +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 ''`: the credential, the identity, the @@ -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. diff --git a/skills/basecamp-connect/watch-requests.sh b/skills/basecamp-connect/watch-requests.sh new file mode 100644 index 000000000..c8eaed514 --- /dev/null +++ b/skills/basecamp-connect/watch-requests.sh @@ -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] " >&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() { + 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