From dc6386d2e494311480042b6679149fc84e5e4f9a Mon Sep 17 00:00:00 2001 From: Thomas Dickson Date: Sun, 6 Sep 2026 08:46:55 +0100 Subject: [PATCH 1/4] Add zj-claude.sh --keep-going: bounded Stop hook for standing-goal spawns DEV-44/DEV-45: zj-claude.sh spawned autonomous-goal workers with no mechanism to enforce the goal to completion, so they parked at the next ordinary turn boundary with the goal unmet. --keep-going installs a session-scoped Stop hook (keep-going-hook.sh) that blocks the session from stopping until it creates .claude/goal-done, bounded by a max continuation count (KEEP_GOING_MAX, default 40) so a broken done-when signal can't loop forever. A human aborts early via .claude/goal-stop. Opt-in per spawn so a one-shot ask still stops normally. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EAvmyrEwiqgQhbaA3w4TsM --- dot_claude/docs/spawning-sessions.md | 10 +++++ dot_claude/scripts/keep-going-hook.sh | 38 +++++++++++++++++++ dot_claude/scripts/keep-going-hook.test.sh | 43 ++++++++++++++++++++++ dot_claude/scripts/zj-claude.sh | 32 +++++++++++++++- 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 dot_claude/scripts/keep-going-hook.sh create mode 100644 dot_claude/scripts/keep-going-hook.test.sh diff --git a/dot_claude/docs/spawning-sessions.md b/dot_claude/docs/spawning-sessions.md index 89949bd..7d50ea5 100644 --- a/dot_claude/docs/spawning-sessions.md +++ b/dot_claude/docs/spawning-sessions.md @@ -35,6 +35,16 @@ Launches `claude --dangerously-skip-permissions --model sonnet` in a fresh tab, `cd`'d into ``. Runs in `` directly — no worktree — so use it for read-only or single-stream work. +Pass `--keep-going` for a standing/autonomous-goal prompt (e.g. "work through this +epic and keep going"). It installs a session-scoped Stop hook +(`keep-going-hook.sh`) in the launch dir's `.claude/settings.local.json` that +blocks the session from stopping until it creates `.claude/goal-done`, bounded by +`KEEP_GOING_MAX` continuations (default 40, override via env). A human aborts the +loop early by creating `.claude/goal-stop` in that worktree — no need to kill the +session. Leave `--keep-going` off for a one-shot ask that should stop at its +natural turn boundary; the hook is opt-in per spawn, not global, because a broken +done-when signal would otherwise loop a plain one-off task too. + **Long/complex prompts → pipe via `@-`.** The script reads stdin into a *unique ephemeral* temp file under `$TMPDIR/claude-spawn` (auto-pruned after a day) and points Claude at it. This avoids cmd.exe quoting issues AND the old foot-gun of hand-writing a diff --git a/dot_claude/scripts/keep-going-hook.sh b/dot_claude/scripts/keep-going-hook.sh new file mode 100644 index 0000000..d750c48 --- /dev/null +++ b/dot_claude/scripts/keep-going-hook.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" +state_dir="$project_dir/.claude" +sentinel="$state_dir/goal-done" +abort="$state_dir/goal-stop" +counter_file="$state_dir/goal-tries" +max_file="$state_dir/keep-going-max" + +cat >/dev/null || true + +if [[ -f "$sentinel" ]]; then + exit 0 +fi + +if [[ -f "$abort" ]]; then + echo "keep-going-hook: $abort present, allowing stop without goal-done." >&2 + exit 0 +fi + +mkdir -p "$state_dir" + +max=40 +[[ -f "$max_file" ]] && max=$(cat "$max_file" 2>/dev/null || echo 40) + +tries=0 +[[ -f "$counter_file" ]] && tries=$(cat "$counter_file" 2>/dev/null || echo 0) +tries=$((tries + 1)) +printf '%s' "$tries" > "$counter_file" + +if (( tries > max )); then + echo "keep-going-hook: hit max continuations ($max) without $sentinel, allowing stop." >&2 + exit 0 +fi + +reason="Standing goal not yet complete ($tries/$max continuations used). Keep working the goal from your original prompt. When it is FULLY met, create .claude/goal-done (e.g. touch .claude/goal-done) before your final message. If you are stuck, say so plainly instead of stopping silently." +printf '{"decision":"block","reason":%s}\n' "$(printf '%s' "$reason" | jq -Rs .)" diff --git a/dot_claude/scripts/keep-going-hook.test.sh b/dot_claude/scripts/keep-going-hook.test.sh new file mode 100644 index 0000000..9411a5c --- /dev/null +++ b/dot_claude/scripts/keep-going-hook.test.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$here/keep-going-hook.sh" +fail=0 + +run() { + local desc="$1"; shift + local tmp out rc + tmp=$(mktemp -d) + mkdir -p "$tmp/.claude" + "$@" "$tmp" + out=$(CLAUDE_PROJECT_DIR="$tmp" bash "$hook" <<<'{}') && rc=0 || rc=$? + if [[ "$rc" != 0 ]]; then + echo "FAIL ($desc): hook exited $rc, expected 0"; fail=1 + fi + rm -rf "$tmp" + printf '%s' "$out" +} + +setup_none() { :; } +setup_done() { touch "$1/.claude/goal-done"; } +setup_stop() { touch "$1/.claude/goal-stop"; } +setup_maxed() { echo 2 > "$1/.claude/keep-going-max"; echo 5 > "$1/.claude/goal-tries"; } + +out=$(run "no sentinel" setup_none) +[[ "$out" == *'"decision":"block"'* ]] || { echo "FAIL: expected block when no sentinel, got: $out"; fail=1; } + +out=$(run "goal-done present" setup_done) +[[ -z "$out" ]] || { echo "FAIL: expected empty stdout with goal-done, got: $out"; fail=1; } + +out=$(run "goal-stop present" setup_stop) +[[ -z "$out" ]] || { echo "FAIL: expected empty stdout with goal-stop, got: $out"; fail=1; } + +out=$(run "max continuations exhausted" setup_maxed) +[[ -z "$out" ]] || { echo "FAIL: expected empty stdout past max, got: $out"; fail=1; } + +if [[ $fail -eq 0 ]]; then + echo "keep-going-hook.test.sh: all cases passed" +else + exit 1 +fi diff --git a/dot_claude/scripts/zj-claude.sh b/dot_claude/scripts/zj-claude.sh index ace16f0..0b37caf 100644 --- a/dot_claude/scripts/zj-claude.sh +++ b/dot_claude/scripts/zj-claude.sh @@ -31,7 +31,8 @@ # No-op (exit 0) when not inside a zellij session. set -euo pipefail -here=0; base=""; setup="" +here=0; base=""; setup=""; keep_going=0 +keep_going_max="${KEEP_GOING_MAX:-40}" model="${MODEL:-sonnet}" settle="${ZJ_SETTLE:-1.5}" # seconds for a new tab's chrome to settle before layout loads @@ -41,6 +42,7 @@ while [[ "${1:-}" == --* ]]; do --base) base="${2:?--base needs a ref}"; shift 2;; --setup) setup="${2:?--setup needs a command}"; shift 2;; --model) model="${2:?--model needs a ref}"; shift 2;; + --keep-going) keep_going=1; shift;; --) shift; break;; *) echo "zj-claude: unknown option $1" >&2; exit 2;; esac @@ -61,12 +63,26 @@ if [[ "$prompt_arg" == "@-" ]]; then promptfile="$tf" elif [[ "$prompt_arg" == @* ]]; then promptfile="${prompt_arg:1}" + if [[ $keep_going -eq 1 ]]; then + tf="$(mktemp "$spawndir/${name}-XXXXXX.md")" + cat "$promptfile" > "$tf" + promptfile="$tf" + fi else tf="$(mktemp "$spawndir/${name}-XXXXXX.md")" printf '%s' "$prompt_arg" > "$tf" promptfile="$tf" fi +if [[ $keep_going -eq 1 ]]; then + { + printf '\n## Standing-goal loop\n' + printf 'A Stop hook will block you from ending this session until the goal above is met, up to %s continuations.\n' "$keep_going_max" + printf 'When the goal is FULLY met, create .claude/goal-done (e.g. `touch .claude/goal-done`) before your final message.\n' + printf 'If a human needs to abort the loop early, they create .claude/goal-stop in this worktree.\n' + } >> "$promptfile" +fi + # ── Worktree creation ──────────────────────────────────────────────────────── if [[ $here -eq 1 ]]; then launch_dir="$workdir" @@ -99,6 +115,20 @@ else fi # ── Path conversion (Windows/MSYS) ────────────────────────────────────────── +if [[ $keep_going -eq 1 ]]; then + mkdir -p "$launch_dir/.claude" + printf '%s' "$keep_going_max" > "$launch_dir/.claude/keep-going-max" + settings_file="$launch_dir/.claude/settings.local.json" + hook_entry='{"matcher":"","hooks":[{"type":"command","command":"bash ~/.claude/scripts/keep-going-hook.sh"}]}' + if [[ -f "$settings_file" ]] && command -v jq >/dev/null 2>&1; then + jq --argjson entry "$hook_entry" '.hooks.Stop = ((.hooks.Stop // []) + [$entry])' "$settings_file" > "$settings_file.tmp" && mv "$settings_file.tmp" "$settings_file" + elif [[ -f "$settings_file" ]]; then + echo "zj-claude: --keep-going: $settings_file already exists and jq is unavailable to merge, skipping hook install" >&2 + else + printf '{"hooks":{"Stop":[%s]}}' "$hook_entry" | jq . > "$settings_file" + fi +fi + to_native() { command -v cygpath >/dev/null && cygpath -m "$1" || printf '%s' "$1"; } bash_native=$(to_native "$(command -v bash)") launch_dir_native=$(to_native "$launch_dir") From d8608a35e02180b546c991ddbfde3b9245f2c03a Mon Sep 17 00:00:00 2001 From: Thomas Dickson Date: Sun, 6 Sep 2026 08:54:19 +0100 Subject: [PATCH 2/4] Fix Windows notifications (DEV-11): replace platform branches with OSC9 zellaude-hook.sh's PermissionRequest notification path had a Darwin branch (terminal-notifier/osascript) and a Linux branch (notify-send) but no Windows branch at all - on Windows a blocking permission request produced only a terminal bell and nothing else surfaced it. Replaced both platform branches with a single OSC9 (ConEmu-style) toast escape sequence written to /dev/tty. zellij passes unrecognised OSC sequences through to the host terminal, and OSC9 is understood by Windows Terminal, iTerm2, and others, so this covers all three platforms with one code path instead of two platform-specific ones that silently degrade on a third. Unfocused mode previously used osascript/xdotool frontmost-window checks with no Windows equivalent, so it silently never fired there. The ticket's proposed fix (read zellij's forwarded host focus state) needs zellij 0.45 (PR 5485), not yet released - installed/available via scoop is still 0.44.3. Rather than ship a focus check that can't work yet, Unfocused now falls back to notifying Always with a once-per-pane stderr warning, so the degradation is visible instead of silent (which was the original bug). Revisit when 0.45 ships. Click-to-focus (the terminal-notifier -execute callback) is dropped: OSC9 carries no callback. Tracked separately as DEV-12. Tested by invoking the hook directly with crafted stdin (Always mode, Unfocused mode incl. one-shot warning, rate-limit window, a non-PermissionRequest event, and malformed JSON for fail-open) rather than relying on live firing in this session. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EAvmyrEwiqgQhbaA3w4TsM --- dot_claude/scripts/zellaude-hook.sh | 65 ++++------------------------- 1 file changed, 9 insertions(+), 56 deletions(-) diff --git a/dot_claude/scripts/zellaude-hook.sh b/dot_claude/scripts/zellaude-hook.sh index 8b96a9d..b06e91a 100644 --- a/dot_claude/scripts/zellaude-hook.sh +++ b/dot_claude/scripts/zellaude-hook.sh @@ -45,12 +45,10 @@ PAYLOAD=$(jq -nc \ ts_ms: ($ts_ms | tonumber) }') -# Permission request: bell + desktop notification +# Permission request: bell + OSC9 desktop notification if [ "$HOOK_EVENT" = "PermissionRequest" ]; then printf '\a' > /dev/tty 2>/dev/null || true - # Read notification setting (default: Always) - # Check Unix path first, then Windows AppData path SETTINGS_FILE="$HOME/.config/zellij/plugins/zellaude.json" if [ ! -f "$SETTINGS_FILE" ] && [ -n "${APPDATA:-}" ]; then WIN_PATH=$(cygpath -u "$APPDATA" 2>/dev/null || echo "") @@ -62,77 +60,32 @@ if [ "$HOOK_EVENT" = "PermissionRequest" ]; then NOTIFY_MODE=$(jq -r '.notifications // "Always"' "$SETTINGS_FILE" 2>/dev/null) fi - # For "Unfocused" mode, check if the terminal app is frontmost SHOULD_NOTIFY=false case "$NOTIFY_MODE" in Always) SHOULD_NOTIFY=true ;; Unfocused) - TERM_FOCUSED=false - case "$(uname)" in - Darwin) - # Map TERM_PROGRAM to macOS process name - EXPECTED="${TERM_PROGRAM:-}" - case "$EXPECTED" in - Apple_Terminal) EXPECTED="Terminal" ;; - iTerm.app) EXPECTED="iTerm2" ;; - esac - FRONT_APP=$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null) - [ "$FRONT_APP" = "$EXPECTED" ] && TERM_FOCUSED=true - ;; - Linux) - # X11: check if focused window belongs to our terminal - if command -v xdotool >/dev/null 2>&1; then - ACTIVE_PID=$(xdotool getactivewindow getwindowpid 2>/dev/null) - if [ -n "$ACTIVE_PID" ]; then - PID=$$ - while [ "$PID" -gt 1 ] 2>/dev/null; do - [ "$PID" = "$ACTIVE_PID" ] && { TERM_FOCUSED=true; break; } - PID=$(ps -o ppid= -p "$PID" 2>/dev/null | tr -d ' ') - done - fi - fi - ;; - esac - [ "$TERM_FOCUSED" = false ] && SHOULD_NOTIFY=true + SHOULD_NOTIFY=true + WARNED="/tmp/zellaude-unfocused-unsupported-${ZELLIJ_PANE_ID}" + if [ ! -f "$WARNED" ]; then + touch "$WARNED" + echo "zellaude-hook: 'Unfocused' needs zellij's forwarded focus state (0.45+, not yet released); notifying Always instead (DEV-11)." >&2 + fi ;; esac if [ "$SHOULD_NOTIFY" = true ]; then TOOL_SUFFIX="" [ -n "$TOOL_NAME" ] && TOOL_SUFFIX=" — $TOOL_NAME" - TITLE="⚠ Claude Code" + TITLE="Claude Code" MESSAGE="Permission requested${TOOL_SUFFIX}" - # Rate-limit: one notification per pane per 10 seconds LOCK="/tmp/zellaude-notify-${ZELLIJ_PANE_ID}" NOW=$(date +%s) LAST=0 [ -f "$LOCK" ] && LAST=$(cat "$LOCK" 2>/dev/null) if [ $((NOW - LAST)) -ge 10 ]; then echo "$NOW" > "$LOCK" - - # Click callback: activate terminal + focus the pane - ZELLIJ_BIN=$(command -v zellij) - FOCUS_CMD="${ZELLIJ_BIN} -s '${ZELLIJ_SESSION_NAME}' pipe --name zellaude:focus -- ${ZELLIJ_PANE_ID}" - - case "$(uname)" in - Darwin) - [ -n "${TERM_PROGRAM:-}" ] && FOCUS_CMD="open -a '${TERM_PROGRAM}' && ${FOCUS_CMD}" - if command -v terminal-notifier >/dev/null 2>&1; then - terminal-notifier \ - -title "$TITLE" \ - -message "$MESSAGE" \ - -execute "$FOCUS_CMD" & - else - osascript -e "display notification \"$MESSAGE\" with title \"$TITLE\"" & - fi - ;; - Linux) - if command -v notify-send >/dev/null 2>&1; then - notify-send "$TITLE" "$MESSAGE" & - fi - ;; - esac + printf '\033]9;%s: %s\033\\' "$TITLE" "$MESSAGE" > /dev/tty 2>/dev/null || true fi fi fi From 68c03094222bb005e0a1acc6d6218d60de2f84dc Mon Sep 17 00:00:00 2001 From: Thomas Dickson Date: Sun, 6 Sep 2026 09:08:09 +0100 Subject: [PATCH 3/4] Add fix-project-path-hook.sh: deny paths misresolved into .claude/projects// (DEV-39) The existing CLAUDE.md prose rule ("confirm a path is relative to the repo root, not ~/.claude/projects//") did not change behaviour: the emergent-tools digest shows 78 occurrences across 53 sessions of Read failing on a path resolved against the transcript-store directory instead of the actual repo. A classification pass correlating tool_use/tool_result pairs across all local transcripts (not just the 5 examples in the digest) found this class dominates at 44% of the 78 occurrences, ahead of genuinely-absent paths (35%), POSIX /tmp on Windows (10%), and tool-artifact-assumed-to-exist (5%) - the latter two are out of scope here and noted as follow-ups rather than folded into this fix. Since prose guidance can't compete with a wrong path already in context, this adds a PreToolUse hook (Read|Edit|Write|Glob|Grep) that detects a path routed through .claude/projects//, computes the likely intended path relative to the real cwd, and denies with the suggested correction instead of letting the tool fail with a bare "File does not exist." Tested by invoking the hook directly with crafted stdin (misresolved path denied with correct suggestion, normal path passes, Glob's `path` field denied, a Bash tool call with no path field passes, and a real Windows-backslash-style payload denied) - see fix-project-path-hook.test.sh, 5/5 passing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EAvmyrEwiqgQhbaA3w4TsM --- dot_claude/scripts/fix-project-path-hook.sh | 25 +++++++++++ .../scripts/fix-project-path-hook.test.sh | 44 +++++++++++++++++++ dot_claude/settings.json | 10 +++++ 3 files changed, 79 insertions(+) create mode 100644 dot_claude/scripts/fix-project-path-hook.sh create mode 100644 dot_claude/scripts/fix-project-path-hook.test.sh diff --git a/dot_claude/scripts/fix-project-path-hook.sh b/dot_claude/scripts/fix-project-path-hook.sh new file mode 100644 index 0000000..41af1a1 --- /dev/null +++ b/dot_claude/scripts/fix-project-path-hook.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +trap 'exit 0' ERR + +input=$(cat) +path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty') +cwd=$(printf '%s' "$input" | jq -r '.cwd // empty') + +[[ -z "$path" ]] && exit 0 + +norm=$(printf '%s' "$path" | tr '\\' '/') +if [[ "$norm" != *"/.claude/projects/"* ]]; then + exit 0 +fi + +remainder=$(printf '%s' "$norm" | sed -E 's#^.*/\.claude/projects/[^/]+/##') + +if [[ -z "$remainder" || "$remainder" == "$norm" || -z "$cwd" ]]; then + echo "Path '$path' runs through ~/.claude/projects//, which is the transcript store, not a repo. Use a path relative to your actual working directory instead." >&2 + exit 2 +fi + +suggestion="${cwd%/}/$remainder" +echo "Path '$path' resolves inside ~/.claude/projects//, which is the transcript store, not your repo root. Your working directory is '$cwd' — you likely meant '$suggestion'. Retry with that path." >&2 +exit 2 diff --git a/dot_claude/scripts/fix-project-path-hook.test.sh b/dot_claude/scripts/fix-project-path-hook.test.sh new file mode 100644 index 0000000..48420fc --- /dev/null +++ b/dot_claude/scripts/fix-project-path-hook.test.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +hook="$here/fix-project-path-hook.sh" +fail=0 + +check() { + local desc="$1" json="$2" want_rc="$3" want_grep="${4:-}" + local out rc + out=$(printf '%s' "$json" | bash "$hook" 2>&1) && rc=0 || rc=$? + if [[ "$rc" != "$want_rc" ]]; then + echo "FAIL ($desc): exit $rc, expected $want_rc. Output: $out"; fail=1 + fi + if [[ -n "$want_grep" && "$out" != *"$want_grep"* ]]; then + echo "FAIL ($desc): output missing '$want_grep'. Output: $out"; fail=1 + fi +} + +check "misresolved Read path" \ + '{"cwd":"C:/Users/tajdi/bestefforttools","tool_input":{"file_path":"C:/Users/tajdi/.claude/projects/C--Users-tajdi-bestefforttools/lib/seo.ts"}}' \ + 2 "C:/Users/tajdi/bestefforttools/lib/seo.ts" + +check "normal repo path passes" \ + '{"cwd":"C:/Users/tajdi/bestefforttools","tool_input":{"file_path":"C:/Users/tajdi/bestefforttools/lib/seo.ts"}}' \ + 0 + +check "misresolved Glob path (path field)" \ + '{"cwd":"C:/Users/tajdi/bestefforttools","tool_input":{"path":"C:/Users/tajdi/.claude/projects/C--Users-tajdi-bestefforttools"}}' \ + 2 + +check "Bash tool with no path field passes" \ + '{"cwd":"C:/Users/tajdi/bestefforttools","tool_input":{"command":"ls"}}' \ + 0 + +check "Windows backslash path" \ + '{"cwd": "C:\\Users\\tajdi\\bestefforttools", "tool_input": {"file_path": "C:\\Users\\tajdi\\.claude\\projects\\C--Users-tajdi-bestefforttools\\lib\\seo.ts"}}' \ + 2 "transcript store" + +if [[ $fail -eq 0 ]]; then + echo "fix-project-path-hook.test.sh: all cases passed" +else + exit 1 +fi diff --git a/dot_claude/settings.json b/dot_claude/settings.json index b4ba48b..8cd4fcf 100644 --- a/dot_claude/settings.json +++ b/dot_claude/settings.json @@ -190,6 +190,16 @@ } ] }, + { + "matcher": "Read|Edit|Write|Glob|Grep", + "hooks": [ + { + "type": "command", + "command": "bash ~/.claude/scripts/fix-project-path-hook.sh", + "timeout": 5 + } + ] + }, { "matcher": "", "hooks": [ From 4d5fd70ab4795f756979365009cda5002f9327c3 Mon Sep 17 00:00:00 2001 From: Thomas Dickson Date: Sun, 6 Sep 2026 09:39:36 +0100 Subject: [PATCH 4/4] Add zj-fleet-status.sh: one aggregated status line per worker (DEV-14) zellaude-hook.sh already emits a JSON payload on every hook event but only forwards it to the zellaude zellij plugin - nothing aggregates it across workers. Sink each event into a shared JSON map (keyed by pane id) at ~/.config/zellij/plugins/zellaude-fleet-state.json (or the Windows AppData equivalent, matching the existing settings-file lookup pattern), guarded by a short mkdir-based lock since many worker panes write the same file concurrently. zj-fleet-status.sh reads that map and prints pane id, ticket slug (derived from the worktree dirname), last hook event, seconds idle, and PR state via `gh pr list --head wt/` (cached 30s so a run doesn't hammer the API once per pane). Plain script + on-demand floating pane, per the ticket's own steer away from a third wasm plugin when a script gets most of the value. zj-fleet-status.test.sh drives both scripts directly with crafted hook stdin (never live firing): upsert creates/overwrites per-pane entries without disturbing others, the renderer picks them up, and both a missing and a malformed state file degrade sanely. 9/9 passing. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01EAvmyrEwiqgQhbaA3w4TsM --- dot_claude/docs/spawning-sessions.md | 15 ++++++ dot_claude/scripts/zellaude-hook.sh | 21 ++++++++- dot_claude/scripts/zj-fleet-status.sh | 55 ++++++++++++++++++++++ dot_claude/scripts/zj-fleet-status.test.sh | 51 ++++++++++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) create mode 100644 dot_claude/scripts/zj-fleet-status.sh create mode 100644 dot_claude/scripts/zj-fleet-status.test.sh diff --git a/dot_claude/docs/spawning-sessions.md b/dot_claude/docs/spawning-sessions.md index 7d50ea5..3d8471f 100644 --- a/dot_claude/docs/spawning-sessions.md +++ b/dot_claude/docs/spawning-sessions.md @@ -118,6 +118,21 @@ Closes the named zellij tab, removes the git worktree at `.wt/`, and deletes the local `wt/` branch. Refuses to delete a branch with unmerged commits unless `--force` is passed. `--no-tab` skips the zellij step (worktree + branch only). +## `zj-fleet-status.sh` — one line per running worker + +```bash +bash ~/.claude/scripts/zj-fleet-status.sh +``` + +`zellaude-hook.sh` already fires on every hook event; it now also upserts each +event into a shared JSON map (keyed by pane id) at +`~/.config/zellij/plugins/zellaude-fleet-state.json` (or the Windows AppData +equivalent). `zj-fleet-status.sh` reads that map and prints pane, ticket slug +(from the worktree dirname), last hook event, seconds idle, and PR state +(`gh pr list --head wt/`, cached for 30s so a run across many panes +doesn't hammer the GitHub API). Run it on demand in a floating pane; it's a +plain script, not a plugin — see DEV-14. + ```bash # Normal teardown after the session merged its branch bash ~/.claude/scripts/zj-worktree-close.sh projektor-access 'C:\Users\tajdi\projektor-workspace' diff --git a/dot_claude/scripts/zellaude-hook.sh b/dot_claude/scripts/zellaude-hook.sh index b06e91a..c9a3ae3 100644 --- a/dot_claude/scripts/zellaude-hook.sh +++ b/dot_claude/scripts/zellaude-hook.sh @@ -90,5 +90,24 @@ if [ "$HOOK_EVENT" = "PermissionRequest" ]; then fi fi -# Send to plugin (hook is already async, no need to background) +FLEET_STATE_DIR="$HOME/.config/zellij/plugins" +if [ ! -d "$FLEET_STATE_DIR" ] && [ -n "${APPDATA:-}" ]; then + WIN_PATH=$(cygpath -u "$APPDATA" 2>/dev/null || echo "") + [ -n "$WIN_PATH" ] && FLEET_STATE_DIR="$WIN_PATH/zellij/plugins" +fi +mkdir -p "$FLEET_STATE_DIR" 2>/dev/null || true +FLEET_STATE_FILE="$FLEET_STATE_DIR/zellaude-fleet-state.json" +LOCKDIR="$FLEET_STATE_FILE.lock" +i=0 +until mkdir "$LOCKDIR" 2>/dev/null; do + i=$((i + 1)) + [ "$i" -ge 20 ] && break + sleep 0.05 +done +CURRENT="{}" +[ -f "$FLEET_STATE_FILE" ] && CURRENT=$(cat "$FLEET_STATE_FILE" 2>/dev/null || echo "{}") +printf '%s' "$CURRENT" | jq --argjson entry "$PAYLOAD" '.[$entry.pane_id | tostring] = $entry' > "$FLEET_STATE_FILE.tmp" 2>/dev/null \ + && mv "$FLEET_STATE_FILE.tmp" "$FLEET_STATE_FILE" +rmdir "$LOCKDIR" 2>/dev/null || true + zellij pipe --name "zellaude" -- "$PAYLOAD" diff --git a/dot_claude/scripts/zj-fleet-status.sh b/dot_claude/scripts/zj-fleet-status.sh new file mode 100644 index 0000000..b2fda22 --- /dev/null +++ b/dot_claude/scripts/zj-fleet-status.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail + +STATE_DIR="$HOME/.config/zellij/plugins" +if [ ! -d "$STATE_DIR" ] && [ -n "${APPDATA:-}" ]; then + WIN_PATH=$(cygpath -u "$APPDATA" 2>/dev/null || echo "") + [ -n "$WIN_PATH" ] && STATE_DIR="$WIN_PATH/zellij/plugins" +fi +STATE_FILE="$STATE_DIR/zellaude-fleet-state.json" +PR_CACHE="${TMPDIR:-/tmp}/zj-fleet-status-pr-cache.json" +PR_CACHE_TTL="${ZJ_FLEET_PR_CACHE_TTL:-30}" + +if [ ! -f "$STATE_FILE" ]; then + echo "No fleet state yet at $STATE_FILE (no hook events recorded)." + exit 0 +fi + +now_ms=$(jq -nc 'now * 1000 | floor') + +pr_state_for() { + local branch="$1" + local now cache_ts cached + now=$(date +%s) + if [ -f "$PR_CACHE" ]; then + cache_ts=$(jq -r '._fetched_at // 0' "$PR_CACHE" 2>/dev/null || echo 0) + if [ $((now - cache_ts)) -lt "$PR_CACHE_TTL" ]; then + cached=$(jq -r --arg b "$branch" '.[$b] // empty' "$PR_CACHE" 2>/dev/null || echo "") + [ -n "$cached" ] && { printf '%s' "$cached"; return; } + fi + fi + if ! command -v gh >/dev/null 2>&1; then + printf 'n/a' + return + fi + local state + state=$(gh pr list --head "$branch" --json state --jq '.[0].state // "none"' 2>/dev/null || echo "none") + local base="{}" + [ -f "$PR_CACHE" ] && base=$(cat "$PR_CACHE" 2>/dev/null || echo "{}") + printf '%s' "$base" | jq --arg b "$branch" --arg s "$state" --argjson ts "$now" \ + '.[$b] = $s | ._fetched_at = $ts' > "$PR_CACHE.tmp" 2>/dev/null && mv "$PR_CACHE.tmp" "$PR_CACHE" + printf '%s' "$state" +} + +printf '%-14s %-20s %-12s %8s %s\n' "PANE" "TICKET" "EVENT" "IDLE(s)" "PR" + +jq -r 'to_entries[] | [.value.pane_id, .value.cwd // "", .value.hook_event, .value.tool_name // "", .value.ts_ms] | @tsv' "$STATE_FILE" | tr -d '\r' | +while IFS=$'\t' read -r pane_id cwd hook_event tool_name ts_ms; do + slug=$(basename "$cwd" 2>/dev/null || echo "?") + idle=$(( (now_ms - ts_ms) / 1000 )) + event_label="$hook_event" + [ -n "$tool_name" ] && event_label="$hook_event:$tool_name" + branch="wt/$slug" + pr=$(pr_state_for "$branch") + printf '%-14s %-20s %-12s %8s %s\n' "$pane_id" "$slug" "$event_label" "$idle" "$pr" +done diff --git a/dot_claude/scripts/zj-fleet-status.test.sh b/dot_claude/scripts/zj-fleet-status.test.sh new file mode 100644 index 0000000..881ae70 --- /dev/null +++ b/dot_claude/scripts/zj-fleet-status.test.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" + +pass=0 +fail=0 +check() { + local desc="$1" got="$2" want="$3" + if [ "$got" = "$want" ]; then + pass=$((pass + 1)) + else + fail=$((fail + 1)) + echo "FAIL: $desc" + echo " want: $want" + echo " got: $got" + fi +} + +TMP=$(mktemp -d) +trap 'rm -rf "$TMP"' EXIT +export HOME="$TMP/home" +mkdir -p "$HOME" + +run_hook() { + ZELLIJ_SESSION_NAME=test-session ZELLIJ_PANE_ID="$1" bash -c "echo '$2' | env -u APPDATA bash zellaude-hook.sh" 2>/dev/null || true +} + +run_hook 1 '{"hook_event_name":"PreToolUse","session_id":"s1","tool_name":"Bash","cwd":"/c/repo.wt/DEV-1"}' >/dev/null +run_hook 2 '{"hook_event_name":"PreToolUse","session_id":"s2","tool_name":"Edit","cwd":"/c/repo.wt/DEV-2"}' >/dev/null + +STATE_FILE="$HOME/.config/zellij/plugins/zellaude-fleet-state.json" +check "state file created" "$([ -f "$STATE_FILE" ] && echo yes || echo no)" "yes" +check "pane 1 tracked" "$(jq -r '."1".cwd' "$STATE_FILE")" "C:/repo.wt/DEV-1" +check "pane 2 tracked" "$(jq -r '."2".hook_event' "$STATE_FILE")" "PreToolUse" + +run_hook 1 '{"hook_event_name":"PostToolUse","session_id":"s1","tool_name":"Bash","cwd":"/c/repo.wt/DEV-1"}' >/dev/null +check "pane 1 overwritten by later event" "$(jq -r '."1".hook_event' "$STATE_FILE")" "PostToolUse" +check "pane 1 cwd unchanged by overwrite" "$(jq -r '."1".cwd' "$STATE_FILE")" "C:/repo.wt/DEV-1" +check "pane count stable after overwrite" "$(jq 'length' "$STATE_FILE")" "2" + +OUT=$(HOME="$HOME" APPDATA= env -u GH_TOKEN bash zj-fleet-status.sh 2>&1) +check "render includes pane 1 ticket slug" "$(printf '%s' "$OUT" | grep -c 'DEV-1')" "1" +check "render includes pane 2 ticket slug" "$(printf '%s' "$OUT" | grep -c 'DEV-2')" "1" + +EMPTY_HOME="$TMP/empty-home" +mkdir -p "$EMPTY_HOME" +OUT2=$(HOME="$EMPTY_HOME" APPDATA= bash zj-fleet-status.sh; echo "exit:$?") +check "no state file yields friendly message and exit 0" "$(printf '%s' "$OUT2" | tail -1)" "exit:0" + +echo "pass=$pass fail=$fail" +[ "$fail" -eq 0 ]