Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions dot_claude/docs/spawning-sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,16 @@ Launches `claude --dangerously-skip-permissions --model sonnet` in a fresh tab,
`cd`'d into `<workdir>`. Runs in `<workdir>` 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
Expand Down Expand Up @@ -108,6 +118,21 @@ Closes the named zellij tab, removes the git worktree at `<repo>.wt/<name>`, and
deletes the local `wt/<name>` 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/<slug>`, 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'
Expand Down
25 changes: 25 additions & 0 deletions dot_claude/scripts/fix-project-path-hook.sh
Original file line number Diff line number Diff line change
@@ -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/<key>/, 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/<key>/, 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
44 changes: 44 additions & 0 deletions dot_claude/scripts/fix-project-path-hook.test.sh
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions dot_claude/scripts/keep-going-hook.sh
Original file line number Diff line number Diff line change
@@ -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 .)"
43 changes: 43 additions & 0 deletions dot_claude/scripts/keep-going-hook.test.sh
Original file line number Diff line number Diff line change
@@ -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
86 changes: 29 additions & 57 deletions dot_claude/scripts/zellaude-hook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 "")
Expand All @@ -62,80 +60,54 @@ 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

# 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"
32 changes: 31 additions & 1 deletion dot_claude/scripts/zj-claude.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
Loading