16 Commits

Author SHA1 Message Date
Paul O'Reilly
f53c6d2980 end-session: 2026-10-26 log, context, README
Claude-Session: https://claude.ai/code/session_01JXneLTuvb5Gxim3DTN2tQa
2026-10-26 00:39:32 +13:00
Paul O'Reilly
d23778942d claude-sessions spawn: add --headless/--create for non-interactive use
Claude-Session: https://claude.ai/code/session_01JXneLTuvb5Gxim3DTN2tQa
2026-10-26 00:26:51 +13:00
Paul O'Reilly
c32ea467e4 claude-sessions: folder browser, unique topic, and location column in menu
Claude-Session: https://claude.ai/code/session_01JXneLTuvb5Gxim3DTN2tQa
2026-10-26 00:26:18 +13:00
Paul O'Reilly
97567b26cc CLAUDE.md: require ISO 24495-1:2023 plain language for responses and documentation 2026-10-19 19:48:16 +13:00
Paul O'Reilly
ee9630a9e5 CLAUDE.md: mm-worktree-task now runs MiniMax M3.1-Flash-Preview (effort max, --m3 for M3) 2026-10-13 10:25:31 +13:00
Paul O'Reilly
bbce7b2bf8 end-session: 2026-10-13 log, context, README
Claude-Session: https://claude.ai/code/session_01SCd4vYV3ig6CpcPV9PCsC6
2026-10-13 10:02:12 +13:00
Paul O'Reilly
11ee50e9c7 claude-sessions: default new session to cwd project, attach after new/resume
Claude-Session: https://claude.ai/code/session_01SCd4vYV3ig6CpcPV9PCsC6
2026-10-13 10:01:31 +13:00
Paul O'Reilly
6b4dea5ffb end-session: session lifecycle management infrastructure
- claude-sessions: registry manager with list/recover/spawn/resume/register/gc and interactive menu
- claude-sessions-shutdown: graceful systemd shutdown handler
- install-systemd.sh: idempotent service installer for new machines
- config/systemd/user/claude-sessions-save.service: versioned service definition
- README + CONTEXT updated
2026-10-12 16:24:30 +13:00
Paul O'Reilly
53170c320e feat: add systemd service config and install script
config/systemd/user/claude-sessions-save.service: source of truth
for the shutdown service, version-controlled alongside the scripts
it calls.

scripts/install-systemd.sh: idempotent install script — copies the
service file to ~/.config/systemd/user/, reloads the daemon, and
enables + starts the service. Run on any new machine to wire up
graceful Claude session shutdown.
2026-10-12 16:11:18 +13:00
Paul O'Reilly
2335a73a02 feat: claude-sessions lifecycle manager + shutdown script
claude-sessions: session registry manager with subcommands:
  - list: live tmux sessions + resumable registry entries
  - recover: scan profile dirs for sessions active before restart
  - spawn/resume: start or resume sessions
  - register: called by claude-tmux to auto-populate registry
  - gc: prune stale entries and backfill session IDs from .jsonl
  - (no args): interactive numbered menu

claude-sessions-shutdown: called by systemd at halt/reboot;
  sends /exit-session to each Claude tmux session, waits up to
  90s for graceful exit, force-kills stragglers.

Symlinks: ~/sbin/claude-sessions, ~/sbin/claude-sessions-shutdown
2026-10-12 15:47:02 +13:00
Paul O'Reilly
77a066edd2 Add skill-supervise memory doc and index entry 2026-10-01 19:49:46 +13:00
Paul O'Reilly
36dfa13b02 gitignore: .claude/ — agent worktrees must never be committed
Claude-Session: https://claude.ai/code/session_014KNyfHmHpMbdMrHZXDRkAp
2026-09-23 19:56:56 +12:00
Paul O'Reilly
2539915a5c Merge U-H2: shell lint that fires, and three checks shellcheck lacks
formatters/sh no longer exits 0 in silence when shfmt/shellcheck are absent —
that silence was the same swallow pattern the linter exists to catch.

Adds SETE-1 (executable script with no set -e), SETE-2 (sh/bash -c with
multiple commands and no set -e inside) and LEAK-1 (secret-named file read into
a string passed to echo/printf/die). Verified against the real defects: SETE-1
and SETE-2 both flag the actual pre-fix sync-deps; LEAK-1 flags the actual
kv-put credential leak. Neither fixed script is flagged.

Supervisor fix on top: SETE-2 was matching line by line and missed the
multi-line sh -c form, i.e. the exact bug it was written for.

Claude-Session: https://claude.ai/code/session_014KNyfHmHpMbdMrHZXDRkAp
2026-09-23 19:49:46 +12:00
Paul O'Reilly
dda01ebeca formatters/sh: SETE-2 must scan the whole file, not line by line
SETE-2 matched `sh -c "..."` with a per-line regex requiring both quotes on one
line. The shape that motivated the check spans lines:

    sh -c "
        hugo mod get ...
        hugo mod tidy
    "

so the real sync-deps bug — the one this check exists for — was never flagged.
Verified against the actual pre-fix sync-deps: now reports at line 358,
"4 commands and no 'set -e' inside".

Scans the file text with re.DOTALL and reports the line of the match start.
Falls back to counting non-blank lines when the body is newline- rather than
;-separated, which is what the multi-line form looks like.

Adds both regression cases (multi-line without set -e must flag; multi-line
with set -e must not). Verified they go red when the whole-file scan is
disabled. Neither of the two fixed real scripts is flagged.

Note for whoever extends this suite: fixtures written with a heredoc inside it
did not behave; the printf form does. Both new tests use printf.

Claude-Session: https://claude.ai/code/session_014KNyfHmHpMbdMrHZXDRkAp
2026-09-23 19:49:33 +12:00
Paul O'Reilly
18861089bf feat(formatters/sh): surface missing shfmt/shellcheck + add SETE/LEAK checks (U-H2) 2026-09-23 19:43:51 +12:00
Paul O'Reilly
59063ddfe9 Merge U-H3: PreToolUse Bash guard for pipe-masked exit status
Supervisor-verified against the two real commands that produced false 'pushed'
messages earlier today (both flagged, rc=2), and against six legitimate
commands from the same session including a correct 'set -o pipefail' form
(none flagged). Advisory only — exit 2 feedback, never blocks.

Claude-Session: https://claude.ai/code/session_014KNyfHmHpMbdMrHZXDRkAp
2026-09-23 19:43:05 +12:00
17 changed files with 1744 additions and 16 deletions

3
.gitignore vendored
View File

@@ -6,3 +6,6 @@
# OS files
.DS_Store
Thumbs.db
# Agent worktrees — never committed.
.claude/

View File

@@ -20,6 +20,19 @@ When any of these apply, lead with the reframe: *"The better question here is pr
**One clarifying question, not many.** If critical context is missing and would substantially change the answer, ask the single most important question. Don't fire off a list.
## Plain Language (ISO 24495-1:2023)
All prose you write for people follows the ISO 24495-1:2023 Plain Language standard. This covers chat responses, and documentation such as README, CLAUDE.md, CONTEXT.md, MEMORY.md, FUTURE.md, specs, plans, runbooks, session logs, commit messages, and PR descriptions. It does not cover code, config, or machine-read formats, where the surrounding conventions win.
Before writing, name the reader and what they need to do with the text. Then check the draft against the standard's four principles:
- **Relevant**: include what this reader needs to act or decide, and leave out the rest. Put the conclusion or required action first.
- **Findable**: use descriptive headings, a logical order, and lists or tables where they help scanning. A reader should locate their answer without reading everything.
- **Understandable**: short sentences, one idea each. Common words over jargon. Define a term or acronym on first use unless the reader already knows it. Use the same word for the same thing throughout. Prefer active voice and concrete subjects.
- **Usable**: write so the reader can do the task with the text alone. Give steps in order, with exact commands, paths, and expected results. Say what to do, not only what to avoid.
Where this conflicts with brevity, keep the plain version. Where it conflicts with exact technical terms, keep the term and define it once.
## Session Start
**Before showing any picker, check for auto-selection signals:**
@@ -69,7 +82,7 @@ When working with Gitea (watching Actions runs, checking whether a release/conta
## Subagent coding tasks — use `mm-worktree-task`
For any coding task delegated to a subagent in a project that has a planner (a `cp-cli planner` reference in its CLAUDE.md), strongly prefer `mm-worktree-task <item-uuid>` over hand-written subagent briefs or `/ask-minimax`. It fetches the work item, its documents, parent and siblings from the planner, prepends the standard implementation preamble, runs MiniMax M3 (1M context) detached in its own git worktree and branch, and can attach the report back to the item (`--collect --upload`). Source and spec: `projects/small-scripts` (`scripts/mm-worktree-task`, `specs/mm-worktree-task.spec.md`); pattern and gotchas: `projects/agent-runtimes/memory/gotchas-minimax.md`. The supervising session still owns review: run the full suite on the merged tree, and check any wire-shape change against the live server model before merging. Fall back to a Claude subagent only when the task has no work item or needs tools MiniMax cannot use.
For any coding task delegated to a subagent in a project that has a planner (a `cp-cli planner` reference in its CLAUDE.md), strongly prefer `mm-worktree-task <item-uuid>` over hand-written subagent briefs or `/ask-minimax`. It fetches the work item, its documents, parent and siblings from the planner, prepends the standard implementation preamble, runs MiniMax M3.1-Flash-Preview (1M context, effort max; `--m3` for M3) detached in its own git worktree and branch, and can attach the report back to the item (`--collect --upload`). Source and spec: `projects/small-scripts` (`scripts/mm-worktree-task`, `specs/mm-worktree-task.spec.md`); pattern and gotchas: `projects/agent-runtimes/memory/gotchas-minimax.md`. The supervising session still owns review: run the full suite on the merged tree, and check any wire-shape change against the live server model before merging. Fall back to a Claude subagent only when the task has no work item or needs tools MiniMax cannot use.
## Process Principles

View File

@@ -2,6 +2,13 @@
Current work focus and active tasks. Used by `context-load` to orient agents at session start.
## Recent
- `claude-sessions` (2026-10-26): menu "new" offers a folder browser (zenity or terminal, with new-folder), a topic prompt (defaults to folder name, must be unique among live sessions), and a Location column. New `spawn --headless [--create]` (exit 2 = topic in use); `/spawn-session` skill uses it. Pushed (c32ea46, d237789; skill a5b82a2). Headless path tested live ("Migration" session). Next: try the menu `n` -> `b` flow by hand and check the Location column.
- `claude-sessions` (2026-10-13): new session defaults to cwd's CLAUDE.md project; new and resumed sessions attach (recover attaches only when one session is selected). Pushed (11ee50e). Not yet tried live end-to-end from the menu.
- Sonnet driver in `small-scripts/scripts/claude-profile` now `claude-sonnet-5-5`; other drivers (haiku, opus, fable) still on older IDs.
- Untracked, left alone: `insights-claude-md-profile-PLAN.md`, `scripts/__pycache__/` (consider gitignoring).
## Active
- [Context loading improvements](context/context-loading.md) — Testing and refining the `context-load` and `start-claude` scripts

View File

@@ -6,6 +6,7 @@
## Scripts
- [claude-sessions](memory/script-claude-sessions.md) — tmux session manager: menu with folder browser, unique topic, location column
- [context-load](memory/script-context-load.md) — Walks cwd upward collecting CLAUDE.md, CONTEXT.md, MEMORY.md, BESTPRACTICES.md, trees, and git status for system prompt injection
- [start-claude](memory/script-start-claude.md) — Wrapper that runs context-load and launches claude with --append-system-prompt
- [install-hooks](memory/script-install-hooks.md) — Symlinks hooks into ~/.claude/hooks/ and prints settings.json config
@@ -41,6 +42,7 @@
- [/review-loop](memory/skill-review-loop.md) — Supervised delivery loop: discover → plan → spec → Sonnet tests → MiniMax worktree code → integrate → deploy → verify live, MiniMax-reviewed at every stage
- [review-loop-run](memory/script-review-loop-run.md) — Launch/track detached MiniMax reviews of review-loop artefacts (verify/plan/spec/tests/code) from per-kind prompt templates; `counts` exits 4 on CRITICAL/HIGH
- [/ci-triage-minimax](memory/skill-ci-triage-minimax.md) — MiniMax triage of failed Gitea Actions runs via gac; failing step + cause file; after `gac tail` exits 1
- [/supervise](memory/skill-supervise.md) — Supervisor mode: 15-min tick dispatches/reaps MiniMax mm-worktree-task units, 30-min progress report; state in gitignored .supervisor/, notes itself in project CLAUDE.md
## References

View File

@@ -27,6 +27,12 @@ claude-foundations/
set-topic.sh # Set per-session topic for the status line
extract-transcripts.py # Transcript backup tracking: register, list, extract, mark-processed
list-transcripts-here.sh # Wrapper: extract-transcripts --list $(pwd) (skill bang-command safe)
claude-sessions # Session lifecycle manager: registry, recover, interactive menu
claude-sessions-shutdown # Graceful shutdown: send /exit-session to all Claude tmux sessions
install-systemd.sh # Install the systemd shutdown service (idempotent, run on each new machine)
config/
systemd/user/
claude-sessions-save.service # Systemd user service — graceful Claude exit before halt/reboot
context/ # Active work focus detail files
memory/ # Session logs and reflections
settings.yaml # Knowledge pipeline configuration
@@ -148,3 +154,6 @@ rm <file>.pre-lint
| `scripts/set-topic.sh` | Set the session topic: `set-topic.sh <cwd> "topic text"` |
| `scripts/extract-transcripts.py` | Transcript backup tracking — register backups, list unprocessed, extract conversation text, mark processed |
| `scripts/list-transcripts-here.sh` | Wrapper calling `extract-transcripts.py --list $(pwd)` — used in `/log` skill pre-gathered context |
| `scripts/claude-sessions` | Session lifecycle manager — registry, interactive menu (new session: folder browser, unique topic, Location column; new/resume land you in tmux), `spawn --headless` for scripts and `/spawn-session`, recover after restart, gc. Symlinked to `~/sbin/` |
| `scripts/claude-sessions-shutdown` | Graceful shutdown — sends `/exit-session` to all Claude tmux sessions, waits, force-kills. Called by systemd |
| `scripts/install-systemd.sh` | Install `config/systemd/user/claude-sessions-save.service` to `~/.config/systemd/user/`, enable and start |

View File

@@ -0,0 +1,18 @@
[Unit]
Description=Gracefully exit Claude Code sessions before shutdown
Documentation=https://github.com/oreillyit/claude-foundations
# Run before the system begins tearing down the session
Before=shutdown.target halt.target reboot.target poweroff.target
[Service]
Type=oneshot
RemainAfterExit=yes
# ExecStart is a no-op — the service exists purely for its ExecStop
ExecStart=/bin/true
# On stop (triggered at shutdown): gracefully exit all Claude tmux sessions
ExecStop=%h/dev/claude/projects/claude-foundations/scripts/claude-sessions-shutdown
# Allow up to 120s for Claude sessions to save and exit cleanly
TimeoutStopSec=120
[Install]
WantedBy=default.target

View File

@@ -1,31 +1,136 @@
#!/usr/bin/env bash
# Formatter: Shell — shfmt + shellcheck
# Formatter: Shell — shfmt + shellcheck + SETE/LEAK checks
# Contract: $1 = absolute file path, format in place, lint, exit 0 if clean / 1 if errors
# Missing tools: exit 0 silently
#
# SETE-1: shebanged script with no `set -e` / `set -euo pipefail` anywhere
# SETE-2: sh/bash -c string with 2+ ;- or newline-separated commands and no set -e inside
# LEAK-1: command substitution reading a secret-named file interpolated into
# echo/printf/die/error/fail argument
#
# Missing shfmt AND shellcheck: emit one warning to stderr, still exit 0
# (a missing tool must not block an edit). Any SETE/LEAK finding: print
# "path:line: ID message" to stderr, exit non-zero.
set -uo pipefail
FILE="$1"
[ -z "$FILE" ] && exit 0
[ -f "$FILE" ] || exit 0
HAD_ERRORS=0
HAS_SHFMT=0
HAS_SHELLCHECK=0
# Format with shfmt if available
if command -v shfmt >/dev/null 2>&1; then
HAS_SHFMT=1
shfmt -w "$FILE" 2>/dev/null
fi
# Lint with shellcheck if available
if command -v shellcheck >/dev/null 2>&1; then
ERRORS=$(shellcheck -f gcc "$FILE" 2>&1)
if [ $? -ne 0 ]; then
HAS_SHELLCHECK=1
if ! ERRORS=$(shellcheck -f gcc "$FILE" 2>&1); then
echo "$ERRORS" >&2
HAD_ERRORS=1
fi
fi
# If neither tool is installed, silently pass
if ! command -v shfmt >/dev/null 2>&1 && ! command -v shellcheck >/dev/null 2>&1; then
exit 0
# --- Custom checks: SETE-1, SETE-2, LEAK-1 ---
# Always run, regardless of shfmt/shellcheck presence. Reads $FILE, emits
# one "path:line: ID message" line per finding to stdout, exits non-zero on
# any finding.
if ! CUSTOM_OUT=$(
python3 - "$FILE" <<'PYEOF'
import re
import sys
filepath = sys.argv[1]
findings = []
try:
with open(filepath, 'r', encoding='utf-8', errors='replace') as f:
content = f.read()
lines = content.splitlines()
except OSError:
sys.exit(0)
# SETE-1: shebanged script with no `set -e` / `set -euo pipefail` anywhere
first = lines[0] if lines else ''
if first.startswith('#!'):
has_sete = False
for line in lines:
if re.search(r'(^|[^A-Za-z0-9_])set\s+-[A-Za-z]*[eE]', line):
has_sete = True
break
if not has_sete:
findings.append((1, "SETE-1 executable shell script without 'set -e'; failed earlier commands will not abort"))
SECRET_RE = re.compile(r'TOKEN|SECRET|KEY|PASSWORD|_JSON_FILE|CREDENTIAL', re.IGNORECASE)
SINK_RE = re.compile(r'\b(echo|printf|die|error|fail)(?=\s|\()')
FILE_READ_RE = re.compile(
r'(\$\(|`)\s*(cat|<)\s*"\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?"\s*(\)|`)'
)
def count_top_level_commands(s):
"""Count ;- or newline-separated top-level commands in a shell string,
ignoring separators inside single or double quotes."""
in_single = False
in_double = False
separators = 0
for c in s:
if not in_double and c == "'":
in_single = not in_single
elif not in_single and c == '"':
in_double = not in_double
elif c in ';\n' and not in_single and not in_double:
separators += 1
return separators + 1
for i, line in enumerate(lines, 1):
# LEAK-1: command substitution reading a secret-named file, inside echo/printf/die/error/fail
sink_match = SINK_RE.search(line)
if sink_match:
cmd = sink_match.group(1)
for cmdsub in FILE_READ_RE.finditer(line):
var = cmdsub.group(3)
if SECRET_RE.search(var):
findings.append((i, "LEAK-1 secret variable '$" + var + "' read into string passed to '" + cmd + "'; could print credential document"))
# SETE-2 runs over the WHOLE source, not line by line: the shape that motivated
# this check spans lines —
# sh -c "
# hugo mod get ...
# hugo mod tidy
# "
# — so a per-line regex requiring both quotes on one line never matched it.
for m in re.finditer(r'\b(?:sh|bash)\s+-c\s+(["\'])(.*?)\1', content, re.DOTALL):
body = m.group(2)
if re.search(r'(^|[^A-Za-z0-9_])set\s+-[A-Za-z]*[eE]', body):
continue
n = count_top_level_commands(body)
if n < 2:
n = len([ln for ln in body.splitlines() if ln.strip() and not ln.strip().startswith('#')])
if n >= 2:
line_no = content[:m.start()].count('\n') + 1
findings.append((line_no, "SETE-2 sh/bash -c with " + str(n) + " commands and no 'set -e' inside; exit status reflects only the last command"))
for line_no, msg in findings:
print(filepath + ":" + str(line_no) + ": " + msg)
sys.exit(1 if findings else 0)
PYEOF
); then
echo "$CUSTOM_OUT" >&2
HAD_ERRORS=1
fi
# If neither shfmt nor shellcheck is installed, warn ONCE so the silent pass
# is no longer silent. A missing tool must not block an edit (would be a worse
# bug than the one this warning surfaces).
if [ "$HAS_SHFMT" -eq 0 ] && [ "$HAS_SHELLCHECK" -eq 0 ]; then
echo "formatters/sh: shfmt and shellcheck not installed; install via 'apt install shfmt shellcheck' (or equivalent). Custom checks still ran." >&2
fi
exit $HAD_ERRORS

View File

@@ -0,0 +1,268 @@
#!/usr/bin/env bash
# tests/test-sh-formatter.sh — assert formatters/sh runs SETE-1/2 and LEAK-1
#
# House convention: bash, set -uo pipefail, SKIP-and-exit-0 when a tool is
# missing, one PASS:/FAIL: line per assertion, FAIL=1 aggregate, ALL_OK on
# full pass.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
FORMATTER="$SCRIPT_DIR/../sh"
# --- SKIP gates: missing tool → exit 0 silently ---
if ! command -v python3 >/dev/null 2>&1; then
echo "SKIP: python3 not on PATH — formatters/sh custom checks need it"
exit 0
fi
if [ ! -x "$FORMATTER" ]; then
echo "SKIP: formatters/sh not found or not executable at $FORMATTER"
exit 0
fi
PASS=0
FAIL=0
pass() {
printf "PASS: %s\n" "$1"
PASS=$((PASS + 1))
}
fail() {
printf "FAIL: %s\n" "$1"
[[ -n "${2:-}" ]] && printf " %s\n" "$2"
FAIL=$((FAIL + 1))
}
TMPDIR="$(mktemp -d)"
trap 'rm -rf "$TMPDIR"' EXIT
# Write a fixture and run the formatter. Assert:
# - exit code matches expected_exit
# - stderr contains the pattern (when expect_pattern is set)
assert_flagged() {
local label="$1"
local expected_exit="$2"
local pattern="$3"
local content="$4"
local f="$TMPDIR/$(printf '%s' "$label" | tr ' /' '__').sh"
printf '%s\n' "$content" > "$f"
local stderr_file="$TMPDIR/$(basename "$f").stderr"
"$FORMATTER" "$f" >/dev/null 2>"$stderr_file"
local actual_exit=$?
local stderr_content
stderr_content="$(cat "$stderr_file")"
if [ "$actual_exit" -ne "$expected_exit" ]; then
fail "$label (exit)" "expected $expected_exit, got $actual_exit. stderr: $stderr_content"
return
fi
if [ -n "$pattern" ] && ! echo "$stderr_content" | grep -qE "$pattern"; then
fail "$label (pattern)" "expected pattern '$pattern', got: $stderr_content"
return
fi
pass "$label"
}
# Write a fixture and run the formatter. Assert:
# - exit code is 0
# - stderr does NOT contain the forbidden pattern
assert_clean() {
local label="$1"
local forbidden="$2"
local content="$3"
local f="$TMPDIR/$(printf '%s' "$label" | tr ' /' '__').sh"
printf '%s\n' "$content" > "$f"
local stderr_file="$TMPDIR/$(basename "$f").stderr"
"$FORMATTER" "$f" >/dev/null 2>"$stderr_file"
local actual_exit=$?
local stderr_content
stderr_content="$(cat "$stderr_file")"
if [ "$actual_exit" -ne 0 ]; then
fail "$label (exit)" "expected 0, got $actual_exit. stderr: $stderr_content"
return
fi
if [ -n "$forbidden" ] && echo "$stderr_content" | grep -qE "$forbidden"; then
fail "$label (forbidden pattern)" "should NOT match '$forbidden', got: $stderr_content"
return
fi
pass "$label"
}
# ===========================================================================
echo "=== SETE-1: shebanged script without set -e ==="
# ===========================================================================
# Positive: shebang, no set -e anywhere → must flag SETE-1, exit 1
assert_flagged "SETE-1 positive" 1 "SETE-1" \
"#!/usr/bin/env bash
echo hello
ls /nonexistent
echo world"
# Negative: shebang + set -euo pipefail → no SETE-1 finding, exit 0
assert_clean "SETE-1 negative (has set -euo pipefail)" "SETE-1" \
"#!/usr/bin/env bash
set -euo pipefail
echo hello
ls /nonexistent || true"
# Negative: shebang + set -e (short form) → no SETE-1 finding, exit 0
assert_clean "SETE-1 negative (has set -e)" "SETE-1" \
"#!/usr/bin/env bash
set -e
echo hello"
# Negative: sourced fragment (no shebang) without set -e → no SETE-1 finding
# `shell=bash` directive satisfies shellcheck's SC2148 ("add a shebang or shell directive")
assert_clean "SETE-1 negative (sourced fragment, no shebang)" "SETE-1" \
"# shellcheck shell=bash
echo hello
ls /nonexistent || true
echo world"
# ===========================================================================
echo ""
echo "=== SETE-2: sh/bash -c with multi-command string and no set -e ==="
# ===========================================================================
# Positive: sh -c with two ;-separated commands and no set -e → flag SETE-2
assert_flagged "SETE-2 positive (hugo mod get; mod tidy)" 1 "SETE-2" \
"#!/usr/bin/env bash
set -euo pipefail
sh -c \"hugo mod get github.com/foo/bar; hugo mod tidy\""
# Positive: bash -c with multiple ; commands, no set -e
assert_flagged "SETE-2 positive (bash -c cmd1; cmd2)" 1 "SETE-2" \
"#!/usr/bin/env bash
set -euo pipefail
bash -c \"echo a; echo b; echo c\""
# Negative: sh -c with single command → no SETE-2 finding
assert_clean "SETE-2 negative (single command)" "SETE-2" \
"#!/usr/bin/env bash
set -euo pipefail
sh -c \"hugo mod tidy\""
# Negative: sh -c with multi commands BUT set -e inside → no SETE-2 finding
assert_clean "SETE-2 negative (has set -e inside)" "SETE-2" \
"#!/usr/bin/env bash
set -euo pipefail
sh -c \"set -e; hugo mod get; hugo mod tidy\""
# ===========================================================================
echo ""
echo "=== LEAK-1: secret-named file read into echo/printf/die/error/fail ==="
# ===========================================================================
# Positive: die "$(cat "$KV_JSON_FILE")" — the canonical real case from the brief
assert_flagged "LEAK-1 positive (die with cat \$KV_JSON_FILE)" 1 "LEAK-1" \
"#!/usr/bin/env bash
set -euo pipefail
die \"kv put failed: \$(cat \"\$KV_JSON_FILE\")\""
# Positive: echo with TOKEN-named file read
assert_flagged "LEAK-1 positive (echo with \$GITHUB_TOKEN_FILE)" 1 "LEAK-1" \
"#!/usr/bin/env bash
set -euo pipefail
echo \"got: \$(cat \"\$GITHUB_TOKEN_FILE\")\""
# Positive: printf with PASSWORD-named file read
assert_flagged "LEAK-1 positive (printf with \$DB_PASSWORD_FILE)" 1 "LEAK-1" \
"#!/usr/bin/env bash
set -euo pipefail
printf 'db password was: %s\n' \"\$(< \"\$DB_PASSWORD_FILE\")\""
# Negative: die with no secret-named variable
assert_clean "LEAK-1 negative (regular error)" "LEAK-1" \
"#!/usr/bin/env bash
set -euo pipefail
die \"kv put failed: connection refused\""
# Negative: die with secret-named var but NOT read via cat/$() — just interpolated
assert_clean "LEAK-1 negative (var interpolated, not cat-substituted)" "LEAK-1" \
"#!/usr/bin/env bash
set -euo pipefail
RESULT_VAR=foo
die \"error code: \$RESULT_VAR\""
# Negative: secret-named var read via cat but NOT in echo/die/etc.
# Mark vars as used via export so shellcheck doesn't flag SC2034.
assert_clean "LEAK-1 negative (secret file read, not in echo)" "LEAK-1" \
"#!/usr/bin/env bash
set -euo pipefail
contents=\$(cat \"\$API_TOKEN_FILE\")
export contents"
# Negative: echo with non-secret variable name
assert_clean "LEAK-1 negative (echo with non-secret var)" "LEAK-1" \
"#!/usr/bin/env bash
set -euo pipefail
echo \"result file content: \$(cat \"\$RESULT_FILE\")\""
# ===========================================================================
echo ""
echo "=== Missing-tool warning (formatters/sh must NOT silently pass) ==="
# ===========================================================================
# When neither tool is on PATH, formatter should emit ONE warning line.
# Build a PATH that has bash + python3 but no shfmt or shellcheck, then run.
PATH_CLEAN="$(mktemp -d)"
ln -sf /usr/bin/bash "$PATH_CLEAN/bash"
ln -sf /usr/bin/python3 "$PATH_CLEAN/python3"
stderr_file="$TMPDIR/no-tools.stderr"
# Use a clean fixture — must not flag any SETE/LEAK.
printf '#!/usr/bin/env bash\nset -euo pipefail\necho hello\n' > "$TMPDIR/clean-fixture.sh"
PATH="$PATH_CLEAN" bash "$FORMATTER" "$TMPDIR/clean-fixture.sh" >/dev/null 2>"$stderr_file"
rc=$?
stderr_content="$(cat "$stderr_file")"
rm -rf "$PATH_CLEAN"
if [ "$rc" -eq 0 ] && echo "$stderr_content" | grep -qE 'formatters/sh: shfmt and shellcheck not installed'; then
pass "missing-tool warning emitted, exit 0"
else
fail "missing-tool warning" "expected exit 0 + warning line, got exit=$rc stderr=$stderr_content"
fi
# SETE-2 must see a MULTI-LINE `sh -c "..."`. That is the shape of the real
# sync-deps bug; the original per-line regex required both quotes on one line
# and missed it entirely.
f="$TMPDIR/sete2-multiline.sh"
printf '#!/usr/bin/env bash\nset -euo pipefail\ndocker run --rm img sh -c "\n hugo mod get a b c\n hugo mod tidy\n"\n' > "$f"
out="$(bash "$FORMATTER" "$f" 2>&1)"
if printf '%s' "$out" | grep -q "SETE-2"; then
pass "SETE-2 flags a multi-line sh -c with no set -e"
else
fail "SETE-2 MISSED the multi-line sh -c form (the real sync-deps bug); got: $out"
fi
f="$TMPDIR/sete2-multiline-ok.sh"
printf '#!/usr/bin/env bash\nset -euo pipefail\ndocker run --rm img sh -c "\n set -e\n hugo mod get a b c\n hugo mod tidy\n"\n' > "$f"
out="$(bash "$FORMATTER" "$f" 2>&1)"
if printf '%s' "$out" | grep -q "SETE-2"; then
fail "SETE-2 false-positived on a multi-line sh -c that DOES set -e"
else
pass "SETE-2 quiet when the multi-line sh -c sets -e"
fi
# ===========================================================================
echo ""
echo "Results: $PASS passed, $FAIL failed"
if [ "$FAIL" -eq 0 ]; then
echo "ALL_OK"
exit 0
fi
exit 1

View File

@@ -0,0 +1,24 @@
# Session Log -- 2026-10-12
## Summary
Built a full Claude Code session lifecycle management system: `claude-sessions` registry manager, `claude-sessions-shutdown` graceful shutdown script, `/exit-session` skill, systemd shutdown service, and registry auto-population in `claude-tmux`. Also extended the skills install script to support all profiles at once.
## Decisions
- Decision: Separate `claude-tmux` (primitive spawner) from `claude-sessions` (lifecycle manager) -- Rationale: clean separation of concerns; `claude-tmux` does one job, `claude-sessions` provides the management layer and delegates back to `claude-tmux` for spawning.
- Decision: Session IDs backfilled lazily by `gc` rather than at registration time -- Rationale: Claude Code assigns the UUID when the process starts, after `claude-tmux` has already called `register`. Backfill scans for the most recent `.jsonl` created after `spawned_at`.
- Decision: Systemd service uses `RemainAfterExit=yes` + no-op `ExecStart` -- Rationale: the service exists solely to run `ExecStop` at shutdown; `RemainAfterExit` keeps it "active" so systemd triggers the stop handler.
- Decision: `install.sh --all-profiles` loops over `~/.claude` and all `~/.claude-*` dirs -- Rationale: profiles are auto-discovered from the filesystem rather than reading profiles.yaml, so new profiles are picked up automatically.
## Gotchas Discovered
- **[skills]** Symptom: `echo "" | claude-sessions recover --days 1` returned 0 results -- Fix: `echo ""` pipes a newline that causes the subprocess to see non-TTY stdin AND closes it immediately; the scan itself worked fine. Always test with heredoc `<<'EOF'\nEOF` or without piping when testing non-interactive behavior.
- **[skills]** Symptom: ANSI escape codes in f-string padding broke column alignment -- Fix: compute padding on the plain string first, then wrap the padded result with color codes; `f"{colored:<32}"` counts escape bytes toward the width.
## Key Context
- `claude-sessions` lives in `claude-foundations/scripts/`, symlinked to `~/sbin/claude-sessions`
- `claude-sessions-shutdown` lives alongside it, symlinked to `~/sbin/claude-sessions-shutdown`
- Systemd service: `~/.config/systemd/user/claude-sessions-save.service` (source of truth: `config/systemd/user/` in this repo)
- `/exit-session` skill installed in all 7 profiles via `custom-claude-skills/scripts/install.sh --all-profiles`
- `claude-tmux` now calls `claude-sessions register` after every spawn (non-fatal if claude-sessions absent)
## Process Notes
- Three repos committed this session: `custom-claude-skills`, `claude-foundations`, `small-scripts` — all unpushed (SSH key not loaded at session end)

View File

@@ -0,0 +1,17 @@
# Session Log -- 2026-10-13
## Summary
Improved `claude-sessions`: new sessions default to the cwd's CLAUDE.md project, and both new and resumed sessions now attach instead of leaving the user at the shell. Moved the default Sonnet model to claude-sonnet-5-5. Pushed claude-foundations and small-scripts.
## Decisions
- Decision: Default model changed in `claude-profile` (small-scripts), not `claude-sessions` -- Rationale: the `sonnet` driver->model-ID map lives there; claude-sessions only passes a mode.
- Decision: `recover` attaches only when exactly one session is selected -- Rationale: cannot attach to several; multi-select stays detached.
- Decision: `claude-sessions resume <id>` CLI still attaches only with `--attach` -- Rationale: scripted use; only menu/recover paths changed.
## Gotchas Discovered
- **[claude-sessions]** Menu "n" called `cmd_spawn` with `attach=False`, and `cmd_spawn` execs `claude-tmux`, so the user was dropped to the shell. Fix: pass `attach=True` (claude-tmux already supports `--attach`, using switch-client inside tmux).
- **[claude-sessions]** `find_claude_md_dir` walks parents up to $HOME, so under `~/dev/claude/<x>` with no CLAUDE.md of its own it offers `~/dev/claude` (root has a CLAUDE.md).
## Key Context
- Model IDs: `projects/small-scripts/scripts/claude-profile` `driver_model_id()`; spec `specs/claude-profile.spec.md`. Other drivers (haiku 4.5, opus 4-8, fable-5) not updated.
- Commits: claude-foundations 11ee50e, small-scripts 3461453.

View File

@@ -0,0 +1,27 @@
# Session Log -- 2026-10-26
## Summary
Added a folder browser, unique-topic prompt and Location column to the `claude-sessions` menu, then added a `--headless` spawn mode and moved the `/spawn-session` skill onto it. Tested live by spawning a "Migration" session.
## Decisions
- Decision: Folder picker uses `zenity` when a display exists, otherwise a built-in terminal browser -- Rationale: sessions are often driven over SSH/tmux where no GUI is available; both support creating a folder.
- Decision: Topic = tmux session name = Remote Control name, defaulting to the Title-Cased folder name; "in use" means a live tmux session only -- Rationale: resumable registry entries do not occupy a tmux name.
- Decision: Headless mode fails (exit 2) on a taken topic instead of adding `#N` -- Rationale: callers (the skill) must ask the user for a real topic so all sessions have meaningful names.
- Decision: `--create` only accepts absolute paths -- Rationale: avoids creating folders in an unexpected cwd.
## Gotchas Discovered
- **[editing]** Symptom: a Python patch script replaced the wrong region of `claude-sessions` and deleted ~450 lines -- Fix: `git checkout` the file and re-apply with the Edit tool. Cause: `s.index('elif raw == "n":')` matched the first occurrence, which was inside newly inserted helper code, not `cmd_menu`. Anchor slice searches after a unique function marker (`s.index(..., s.index("def cmd_menu"))`).
- **[tmux]** Symptom: `tmux capture-pane -t "=Name"` returns "can't find pane" -- Fix: use the bare session name for pane targets; `=` exact-match works only for session targets (`has-session`, `attach`, `switch-client`).
- **[tmux]** tmux rewrites `.` and `:` in session names to `_`, so topic checks compare the rewritten form and reject those characters.
## Open Questions
- Menu path not yet exercised by hand end to end (`n` -> `b` -> topic -> attach). Only helper functions and the menu listing were tested.
- Does the Location column look right for the new "Migration" session in the live menu?
## Key Context
- Script: `projects/claude-foundations/scripts/claude-sessions`; commits `c32ea46` (menu) and `d237789` (headless). Skill: `custom-claude-skills/skills/spawn-session`, commit `a5b82a2`.
- Headless: `claude-sessions spawn <folder> --headless [--name topic] [--create]`; exit 1 = folder missing, exit 2 = topic in use.
- Live test: skill spawned "Migration" in `~/dev/claude/projects/migration`; Remote Control active.
## Process Notes
- Script docs rule followed: new `memory/script-claude-sessions.md` plus MEMORY.md index line; `skill-spawn-session.md` updated.

View File

@@ -0,0 +1,26 @@
---
name: script-claude-sessions
description: claude-sessions — tmux session manager; interactive menu with folder picker, unique topic, and location column
metadata:
type: reference
---
# claude-sessions
Python script (`scripts/claude-sessions`, symlinked to `~/sbin/`). Manages Claude Code tmux sessions: list, recover, resume, spawn, gc. Run with no args for the interactive menu.
## Menu behaviour (2026-10-26)
- **Location column.** Live and resumable rows show the session folder (`~` for `$HOME`). Live rows take it from the registry (`project_dir`), falling back to tmux `#{session_path}`.
- **`n` New session.** Asks for a folder: Enter accepts the nearest `CLAUDE.md` folder, `b` opens the folder browser, or type a project name/path (same lookup as `claude-tmux`).
- **Folder browser.** `zenity --file-selection --directory` when `DISPLAY`/`WAYLAND_DISPLAY` is set and zenity exists (has Create Folder). Otherwise a terminal browser: number = open, `u` up, `n` new folder, `/text` filter, `>` `<` page, `h` hidden, `~`/`/path` jump, Enter/`s` select, `q` cancel.
- **Topic.** Becomes the tmux session name and the Remote Control name (passed as `--name` to `claude-tmux`). Defaults to the Title-Cased folder name. If that name is already a live tmux session, no default is offered and a unique topic is required. Topics containing `.` or `:` are rejected because tmux rewrites them to `_`.
## Headless spawn
`claude-sessions spawn <folder> --headless [--name topic] [--create] [--mode m] [--profile p]` — no prompts, no attach, for scripts and the `/spawn-session` skill. Exit 1 = folder missing (or `--create` needs an absolute path); exit 2 = topic in use (default topic is the Title-Cased folder name; no `#N` suffix is added). Without `--headless`, `spawn` behaves as before.
## Gotchas
- "In use" means a live tmux session. Resumable registry entries do not block a topic.
- `claude-tmux` still auto-appends `#N` on a collision, but the menu rejects collisions first.

View File

@@ -12,14 +12,14 @@ from the Claude app.
- `/spawn-session <project>` — spawn for a named project folder
- `/spawn-session <project> --mode <mode>` — override engagement mode
- `/spawn-session <project> --attach` — attach after spawning
- `/spawn-session` — lists projects and asks which one
## How it works
Thin wrapper over the `claude-tmux` script (in `small-scripts`, symlinked to `~/sbin`).
The skill only resolves the project name from `$ARGUMENTS` (or asks) and shells out to
`claude-tmux <project> [flags]`. All real logic — folder resolution, mode selection
Calls `claude-sessions spawn <folder> --headless [--name topic] [--create]` (since 2026-10-26),
which wraps `claude-tmux` (in `small-scripts`, symlinked to `~/sbin`). The skill only resolves
the folder from `$ARGUMENTS` (or asks). Headless mode never prompts or attaches, and exits 2
if the topic is already a live tmux session (the skill then asks for a unique topic). All real logic — folder resolution, mode selection
(profile `last-mode` unless `--mode`), Title-Casing, ` #N` auto-increment, tmux creation,
and launching `claude --remote-control` via `claude-profile` — lives in the script.

24
memory/skill-supervise.md Normal file
View File

@@ -0,0 +1,24 @@
# /supervise Skill
**Purpose:** Run a session as a supervisor over MiniMax subagents. A 15-min cron tick reaps finished `mm-worktree-task` units, files new bugs, and dispatches a unit per in-focus work item; a 30-min cron report gives bugs worked/resolved and feature progress. Replaces two hand-pasted loop prompts.
**Location:** `custom-claude-skills/skills/supervise/SKILL.md`.
## Usage
`/supervise [focus text]` (bare = pick focus from the planner via AskUserQuestion), then `/supervise focus|push on|off|status|stop`. `tick` and `report` are what the crons fire.
Requires: planner-backed project (`cp-cli planner`), `mm-worktree-task`, MiniMax profile.
## How it works
- Cron prompts are just `/supervise tick` / `/supervise report`; all logic and memory live in the skill and `.supervisor/state.md` (focus, ledger, in-flight units, cron ids).
- On start it creates `.supervisor/`, adds it to `.gitignore`, and appends a marked `<!-- supervisor:begin -->` section to the project CLAUDE.md (left uncommitted) so other sessions know a supervisor owns those worktrees.
- Report counts come from the ledger, with "resolved" verified against the planner.
- Merged work stays local unless `push: on` (pushes may trigger deploys).
## Gotchas
- Crons are session-only and auto-expire after 7 days; after a restart `/supervise` resumes from state and recreates them.
- One unit per item (no cap on items) — a bare "dispatch if none underway" duplicates units; the in-flight table prevents it. Items with overlapping files are queued, not parallelised.
- Always re-run the suite after a unit's last commit; units' own green claims are not trusted.

991
scripts/claude-sessions Executable file
View File

@@ -0,0 +1,991 @@
#!/usr/bin/env python3
"""
claude-sessions — Session lifecycle manager for Claude Code tmux sessions.
Commands:
(no args) Interactive menu (new session: folder browser + unique topic)
list Show live Claude tmux sessions and resumable registry entries
recover [--days N] Detect sessions active before a restart
spawn <project> Spawn a new session (wraps claude-tmux); --headless for scripted use
resume <session-id> Resume a past session by conversation ID
register Record a new session in the registry (called by claude-tmux)
gc [--days N] Prune stale entries and backfill session IDs
"""
import argparse
import json
import os
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
# === Terminal colours ===
RED = "\033[0;31m"
GREEN = "\033[0;32m"
YELLOW = "\033[0;33m"
CYAN = "\033[0;36m"
BOLD = "\033[1m"
DIM = "\033[2m"
NC = "\033[0m"
# === Paths ===
HOME = Path.home()
CLAUDE_ROOT = HOME / "dev" / "claude"
CONFIG_FILE = HOME / ".config" / "claude" / "profiles.yaml"
REGISTRY_FILE = HOME / ".local" / "share" / "claude-sessions" / "registry.json"
HOSTNAME = subprocess.run(["hostname", "-s"], capture_output=True, text=True).stdout.strip()
PATH_SCAN_ROOTS = [
(HOME / "dev" / "claude", 2),
(HOME / "dev" / "claude" / "octopus", 1),
(HOME / "dev" / "claude" / "gamedev", 1),
(HOME / "dev" / "claude" / "worktrees", 1),
(HOME, 1),
]
DEFAULT_PROFILES = {
"oreillyit-anthropic": {
"display_name": "O'Reilly IT",
"claude_dir": str(HOME / ".claude-oreillyit-anthropic"),
"default_mode": "quick",
},
"default": {
"display_name": "Personal",
"claude_dir": str(HOME / ".claude"),
"default_mode": "quick",
},
}
# ── Helpers ──────────────────────────────────────────────────────────────────
def warn(msg: str) -> None:
print(f"{YELLOW}Warning:{NC} {msg}", file=sys.stderr)
def die(msg: str) -> None:
print(f"{RED}Error:{NC} {msg}", file=sys.stderr)
sys.exit(1)
# ── Profiles ────────────────────────────────────────────────────────────────
def load_profiles() -> dict:
if CONFIG_FILE.exists():
try:
import yaml
with CONFIG_FILE.open() as f:
config = yaml.safe_load(f)
if config and "profiles" in config:
profiles = config["profiles"]
for p in profiles.values():
if "claude_dir" in p:
p["claude_dir"] = str(Path(p["claude_dir"]).expanduser())
return profiles
except Exception as e:
warn(f"Could not load {CONFIG_FILE}: {e}")
return DEFAULT_PROFILES
def all_profile_dirs(profiles: dict) -> list[tuple[str, Path]]:
result = []
for name, cfg in profiles.items():
d = Path(cfg.get("claude_dir", "")).expanduser()
if d.is_dir():
result.append((name, d))
for d in HOME.iterdir():
if d.is_dir() and d.name.startswith(".claude-") and not d.name.endswith((".json", ".backup")):
pname = d.name[len(".claude-"):]
if not any(n == pname for n, _ in result):
result.append((pname, d))
return result
# ── Registry ────────────────────────────────────────────────────────────────
def load_registry() -> dict:
if REGISTRY_FILE.exists():
try:
return json.loads(REGISTRY_FILE.read_text())
except Exception:
pass
return {"sessions": []}
def save_registry(registry: dict) -> None:
REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True)
REGISTRY_FILE.write_text(json.dumps(registry, indent=2))
# ── Path hash utilities ──────────────────────────────────────────────────────
def path_to_hash(path: str | Path) -> str:
return str(path).replace("/", "-")
def build_path_map() -> dict[str, str]:
result: dict[str, str] = {}
def scan(p: Path, depth: int) -> None:
if not p.is_dir() or depth < 0:
return
result[path_to_hash(p)] = str(p)
if depth > 0:
try:
for child in p.iterdir():
if child.is_dir() and not child.name.startswith("."):
scan(child, depth - 1)
except PermissionError:
pass
for root, depth in PATH_SCAN_ROOTS:
scan(root, depth)
return result
# ── tmux utilities ───────────────────────────────────────────────────────────
def list_tmux_sessions() -> set[str]:
try:
r = subprocess.run(["tmux", "ls", "-F", "#{session_name}"], capture_output=True, text=True)
if r.returncode == 0:
return set(r.stdout.strip().splitlines())
except Exception:
pass
return set()
def title_case(s: str) -> str:
return " ".join(w.capitalize() for w in s.replace("-", " ").replace("_", " ").split())
def live_session_paths() -> dict[str, str]:
"""Map live tmux session name -> directory the session was created in."""
try:
r = subprocess.run(["tmux", "ls", "-F", "#{session_name}\t#{session_path}"],
capture_output=True, text=True)
except Exception:
return {}
if r.returncode != 0:
return {}
result = {}
for line in r.stdout.splitlines():
name, _, path = line.partition("\t")
result[name] = path
return result
def short_path(path: str) -> str:
"""Abbreviate $HOME to ~ for display."""
if not path:
return "?"
home = str(HOME)
if path == home:
return "~"
if path.startswith(home + "/"):
return "~" + path[len(home):]
return path
def resolve_project_dir(p: str) -> Optional[Path]:
"""Same lookup order as claude-tmux: literal path, then under CLAUDE_ROOT, then CLAUDE_ROOT/projects."""
for cand in (Path(p).expanduser(), CLAUDE_ROOT / p, CLAUDE_ROOT / "projects" / p):
if cand.is_dir():
return cand.resolve()
return None
def topic_in_use(topic: str, live: set[str]) -> bool:
# tmux rewrites '.' and ':' in session names to '_', so compare the stored form.
return topic in live or topic.replace(".", "_").replace(":", "_") in live
# ── Folder picker ────────────────────────────────────────────────────────────
def pick_folder_gui(start: Path) -> Optional[Path]:
"""GTK folder chooser via zenity (has a Create Folder button). None if cancelled or unavailable."""
if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")):
return None
if subprocess.run(["which", "zenity"], capture_output=True).returncode != 0:
return None
r = subprocess.run(
["zenity", "--file-selection", "--directory",
"--title=Select a folder for the new Claude session",
f"--filename={start}/"],
capture_output=True, text=True,
)
if r.returncode == 0 and r.stdout.strip():
return Path(r.stdout.strip())
return None
def pick_folder_tui(start: Path, page_size: int = 15) -> Optional[Path]:
"""Terminal folder browser with new-folder support. Returns None if cancelled."""
cur = start.resolve() if start.is_dir() else HOME
flt = ""
page = 0
show_hidden = False
while True:
try:
subdirs = sorted(
(d for d in cur.iterdir()
if d.is_dir() and (show_hidden or not d.name.startswith("."))),
key=lambda d: d.name.lower(),
)
except PermissionError:
subdirs = []
warn(f"Cannot read {cur}")
if flt:
subdirs = [d for d in subdirs if flt.lower() in d.name.lower()]
pages = max(1, -(-len(subdirs) // page_size))
page = min(page, pages - 1)
shown = subdirs[page * page_size:(page + 1) * page_size]
os.system("clear 2>/dev/null || true")
print(f"\n{BOLD}Select folder{NC} {CYAN}{short_path(str(cur))}{NC}")
print("─" * 50)
if flt:
print(f" {DIM}filter: {flt!r}{NC}")
if not shown:
print(f" {DIM}(no subfolders){NC}")
for i, d in enumerate(shown, 1):
print(f" {CYAN}{i:>2}){NC} {d.name}/")
if pages > 1:
print(f" {DIM}page {page + 1}/{pages}{NC}")
print(f"\n {DIM}number = open · Enter or s = select this folder · u = up · n = new folder{NC}")
print(f" {DIM}/text = filter · > < = page · h = hidden ({'on' if show_hidden else 'off'}) · ~ or /path = jump · q = cancel{NC}")
try:
raw = input(" > ").strip()
except (KeyboardInterrupt, EOFError):
print()
return None
if raw in ("", "s"):
return cur
elif raw == "q":
return None
elif raw == "u":
cur, flt, page = cur.parent, "", 0
elif raw == ">":
page = min(page + 1, pages - 1)
elif raw == "<":
page = max(page - 1, 0)
elif raw == "h":
show_hidden = not show_hidden
page = 0
elif raw == "n":
try:
name = input(" New folder name: ").strip()
except (KeyboardInterrupt, EOFError):
print()
continue
if not name:
continue
if "/" in name or name in (".", ".."):
print(f" {YELLOW}Name must not contain '/' or be '.'/'..'{NC}")
time.sleep(1.2)
continue
target = cur / name
if target.exists():
print(f" {YELLOW}{name} already exists{NC}")
time.sleep(1.2)
continue
try:
target.mkdir()
except OSError as e:
print(f" {YELLOW}Could not create folder: {e}{NC}")
time.sleep(1.5)
continue
cur, flt, page = target, "", 0
elif raw.startswith("/") and len(raw) > 1 and not Path(raw).is_dir():
flt, page = raw[1:], 0
elif raw.startswith(("/", "~")):
dest = Path(raw).expanduser()
if dest.is_dir():
cur, flt, page = dest.resolve(), "", 0
else:
print(f" {YELLOW}Not a folder: {raw}{NC}")
time.sleep(1.2)
elif raw.isdigit() and 1 <= int(raw) <= len(shown):
cur, flt, page = shown[int(raw) - 1], "", 0
else:
print(f" {YELLOW}Unknown choice: {raw!r}{NC}")
time.sleep(1)
def pick_folder(start: Path) -> Optional[Path]:
return pick_folder_gui(start) or pick_folder_tui(start)
def prompt_topic(folder: Path, live: set[str]) -> Optional[str]:
"""Topic defaults to the folder name; if that is taken, the user must supply a unique one."""
default = title_case(folder.name)
taken = topic_in_use(default, live)
if taken:
print(f" {YELLOW}Topic '{default}' is already in use — enter a unique topic.{NC}")
while True:
try:
if taken:
topic = input(" Topic: ").strip()
else:
topic = input(f" Topic [{BOLD}{default}{NC}]: ").strip() or default
except (KeyboardInterrupt, EOFError):
print()
return None
if not topic:
print(f" {YELLOW}A topic is required.{NC}")
elif topic_in_use(topic, live):
print(f" {YELLOW}Topic '{topic}' is already in use — enter a different one.{NC}")
elif any(c in topic for c in ".:"):
print(f" {YELLOW}Topic must not contain '.' or ':' (tmux rewrites them).{NC}")
else:
return topic
# ── Session file parsing ─────────────────────────────────────────────────────
def read_jsonl_header(path: Path, max_lines: int = 30) -> dict:
session_id = None
start_time = None
try:
with path.open(errors="replace") as f:
for i, line in enumerate(f):
if i >= max_lines:
break
try:
obj = json.loads(line.strip())
if not session_id and "sessionId" in obj:
session_id = obj["sessionId"]
if not start_time and "timestamp" in obj:
start_time = obj["timestamp"]
if session_id and start_time:
break
except json.JSONDecodeError:
pass
except Exception:
pass
return {"session_id": session_id or path.stem, "start_time": start_time}
def scan_profile_sessions(profile_name: str, claude_dir: Path,
path_map: dict[str, str], cutoff: float) -> list[dict]:
projects_dir = claude_dir / "projects"
if not projects_dir.is_dir():
return []
records = []
for proj_dir in projects_dir.iterdir():
if not proj_dir.is_dir():
continue
project_hash = proj_dir.name
real_path = path_map.get(project_hash)
jsonl_files = sorted(
proj_dir.glob("*.jsonl"),
key=lambda f: f.stat().st_mtime,
reverse=True,
)
for jf in jsonl_files:
mtime = jf.stat().st_mtime
if mtime < cutoff:
break
meta = read_jsonl_header(jf)
records.append({
"session_id": meta["session_id"],
"profile": profile_name,
"project_hash": project_hash,
"project_dir": real_path,
"start_time": meta["start_time"],
"last_activity": datetime.fromtimestamp(mtime).strftime("%b %d %H:%M"),
"mtime": mtime,
})
return records
# ── Registry backfill ────────────────────────────────────────────────────────
def backfill_session_ids(registry: dict, profiles: dict) -> int:
"""For registry entries with no session_id, find the matching .jsonl by scan."""
updated = 0
for entry in registry.get("sessions", []):
if entry.get("session_id"):
continue
profile_name = entry.get("profile", "")
profile_cfg = profiles.get(profile_name, {})
claude_dir = Path(profile_cfg.get("claude_dir", "")).expanduser()
if not claude_dir.is_dir():
continue
project_dir = entry.get("project_dir", "")
if not project_dir:
continue
proj_hash = path_to_hash(project_dir)
proj_path = claude_dir / "projects" / proj_hash
if not proj_path.is_dir():
continue
spawned_at = entry.get("spawned_at", "")
spawn_ts = 0.0
if spawned_at:
try:
spawn_ts = datetime.fromisoformat(spawned_at).timestamp()
except ValueError:
pass
best: Optional[Path] = None
best_mtime = 0.0
for jf in proj_path.glob("*.jsonl"):
mtime = jf.stat().st_mtime
if mtime > spawn_ts and mtime > best_mtime:
best, best_mtime = jf, mtime
if best:
meta = read_jsonl_header(best)
entry["session_id"] = meta["session_id"]
entry["last_activity"] = datetime.fromtimestamp(best_mtime).isoformat()
updated += 1
return updated
# ── Spawning ─────────────────────────────────────────────────────────────────
def resolve_claude_profile_bin() -> str:
r = subprocess.run(["which", "claude-profile"], capture_output=True, text=True)
if r.returncode == 0 and r.stdout.strip():
return r.stdout.strip()
die("claude-profile not found in PATH")
def spawn_session(project_dir: str, profile: str, mode: str,
resume_id: Optional[str] = None,
name_override: Optional[str] = None,
attach: bool = False) -> bool:
proj_path = Path(project_dir)
if not proj_path.is_dir():
warn(f"Project directory not found: {project_dir}")
return False
project_basename = proj_path.name
base_name = name_override or title_case(project_basename)
if resume_id:
base_name = f"{base_name} (resume)"
live = list_tmux_sessions()
name = base_name
n = 2
while name in live:
name = f"{base_name} #{n}"
n += 1
rc_flag = f'--remote-control "{name}"'
resume_flag = f"--resume {resume_id}" if resume_id else ""
extra = " ".join(filter(None, [rc_flag, resume_flag]))
claude_profile_bin = resolve_claude_profile_bin()
launch_cmd = (
f"{claude_profile_bin} {profile} --mode {mode} "
f"--project {project_basename} -- {extra}"
)
r = subprocess.run(
["tmux", "new-session", "-d", "-s", name, "-c", str(proj_path)],
capture_output=True,
)
if r.returncode != 0:
warn(f"tmux new-session failed: {r.stderr.decode().strip()}")
return False
subprocess.run(["tmux", "send-keys", "-t", name, "-l", launch_cmd])
subprocess.run(["tmux", "send-keys", "-t", name, "Enter"])
print(f"{GREEN}Spawned{NC} {BOLD}{name}{NC}")
print(f" dir: {project_dir}")
print(f" profile: {profile} / mode: {mode}")
if resume_id:
print(f" resume: {resume_id[:8]}…")
print(f" attach: tmux attach -t \"{name}\"")
if attach:
if os.environ.get("TMUX"):
subprocess.run(["tmux", "switch-client", "-t", f"={name}"])
else:
os.execvp("tmux", ["tmux", "attach", "-t", f"={name}"])
return True
def find_claude_md_dir(start: Path) -> Optional[Path]:
"""Nearest directory at or above start (stopping at $HOME) containing a CLAUDE.md."""
for d in [start, *start.parents]:
if d == HOME.parent or d == Path("/"):
break
if (d / "CLAUDE.md").is_file():
return d
if d == HOME:
break
return None
# ── Commands ─────────────────────────────────────────────────────────────────
def cmd_list(args) -> None:
live = list_tmux_sessions()
if live:
print(f"\n{BOLD}Live tmux sessions:{NC}")
for s in sorted(live):
print(f" {GREEN}●{NC} {s}")
else:
print(f"\n{DIM}No active tmux sessions.{NC}")
registry = load_registry()
resumable = [s for s in registry.get("sessions", []) if s.get("tmux_name") not in live]
if resumable:
print(f"\n{BOLD}Resumable (registry, not currently live):{NC}")
fmt = " {:<4} {:<32} {:<24} {:<10} {}"
print(fmt.format("", "Project", "Profile", "Session ID", "Last active"))
print(fmt.format("", "-" * 32, "-" * 24, "-" * 10, "-" * 15))
for i, s in enumerate(resumable, 1):
proj = Path(s.get("project_dir", "")).name or s.get("project", "?")
sid = (s.get("session_id") or "")[:8] or "pending…"
ts = (s.get("last_activity") or s.get("spawned_at") or "?")[:16]
profile = s.get("profile", "?")
print(fmt.format(f"{i})", proj, profile, sid, ts))
else:
print(f"\n{DIM}Registry is empty — run 'spawn' to start tracking sessions.{NC}")
print()
def cmd_recover(args) -> None:
profiles = load_profiles()
path_map = build_path_map()
cutoff = time.time() - args.days * 86400
all_sessions: list[dict] = []
for profile_name, claude_dir in all_profile_dirs(profiles):
all_sessions.extend(scan_profile_sessions(profile_name, claude_dir, path_map, cutoff))
all_sessions.sort(key=lambda s: s["mtime"], reverse=True)
if not all_sessions:
print(f"No sessions found in the last {args.days} days.")
return
print(f"\n{BOLD}Sessions active in the last {args.days} day(s):{NC}\n")
print(f" {'#':<4} {'Project':<32} {'Profile':<24} {'Session ID':<10} {'Last Active':<16} Note")
print(f" {'-'*4} {'-'*32} {'-'*24} {'-'*10} {'-'*16} {'-'*20}")
for i, s in enumerate(all_sessions, 1):
if s["project_dir"]:
proj_raw = Path(s["project_dir"]).name
proj = proj_raw[:30] + ("…" if len(proj_raw) > 30 else "")
dim_proj = ""
else:
h = s["project_hash"]
for prefix in ("-home-paul-", "-root-", "-home-"):
if h.startswith(prefix):
h = h[len(prefix):]
break
parts = h.rsplit("-", 4)
hint = "/".join(parts[-2:]) if len(parts) >= 2 else h
proj = hint[:30]
dim_proj = DIM
sid = s["session_id"][:8]
ts = s["last_activity"]
profile_raw = s["profile"]
profile = profile_raw[:22] + ("…" if len(profile_raw) > 22 else "")
note = "" if s["project_dir"] else f"{YELLOW}path?{NC}"
proj_padded = f"{proj:<32}"
print(f" {i:<4} {dim_proj}{proj_padded}{NC if dim_proj else ''} {profile:<24} {sid:<10} {ts:<16} {note}")
print()
if not sys.stdin.isatty():
return
try:
raw = input("Resume which sessions? (e.g. 1,3 or 'all', Enter to skip): ").strip()
except (KeyboardInterrupt, EOFError):
print()
return
if not raw:
return
if raw.lower() == "all":
selected = [s for s in all_sessions if s["project_dir"]]
else:
try:
indices = [int(x.strip()) - 1 for x in raw.replace(" ", "").split(",")]
selected = [all_sessions[i] for i in indices if 0 <= i < len(all_sessions)]
except ValueError:
die("Invalid selection — expected numbers like 1,3,5 or 'all'")
return
if not selected:
return
profiles_cfg = load_profiles()
print()
# Land in the session only when resuming exactly one; several stay detached.
attach = len(selected) == 1
for s in selected:
if not s["project_dir"]:
warn(f"Skipping: project directory not found for {s['project_hash']}")
continue
profile_cfg = profiles_cfg.get(s["profile"], {})
mode = getattr(args, "mode", None) or profile_cfg.get("default_mode", "quick")
spawn_session(project_dir=s["project_dir"], profile=s["profile"],
mode=mode, resume_id=s["session_id"], attach=attach)
print()
def cmd_spawn(args) -> None:
claude_tmux = subprocess.run(["which", "claude-tmux"], capture_output=True, text=True).stdout.strip()
if not claude_tmux:
die("claude-tmux not found in PATH")
project = args.project
name = args.name
if getattr(args, "headless", False):
# Non-interactive: no prompts, no attach. Errors exit non-zero instead of asking.
folder = resolve_project_dir(project)
if folder is None and args.create:
folder = Path(project).expanduser()
if not folder.is_absolute():
die(f"--create needs an absolute path (got {project!r})")
try:
folder.mkdir(parents=True)
except OSError as e:
die(f"Could not create {folder}: {e}")
folder = folder.resolve()
if folder is None:
die(f"Folder not found: {project} (use --create with an absolute path to make it)")
name = name or title_case(folder.name)
if topic_in_use(name, list_tmux_sessions()):
print(f"{RED}Error:{NC} topic '{name}' is already in use — pass a unique --name", file=sys.stderr)
sys.exit(2)
if any(c in name for c in ".:"):
die("Topic must not contain '.' or ':' (tmux rewrites them)")
project = str(folder)
args.attach = False
cmd = [claude_tmux, project]
if args.mode:
cmd += ["--mode", args.mode]
if args.profile:
cmd += ["--profile", args.profile]
if name:
cmd += ["--name", name]
if args.attach:
cmd += ["--attach"]
os.execvp(claude_tmux, cmd)
def cmd_resume(args) -> None:
session_id = args.session_id
registry = load_registry()
entry = next(
(s for s in registry.get("sessions", [])
if (s.get("session_id") or "").startswith(session_id)),
None,
)
if entry:
project_dir = entry.get("project_dir", "")
profile = args.profile or entry.get("profile", "oreillyit-anthropic")
mode = args.mode or entry.get("mode", "quick")
else:
if not args.project:
die(f"Session {session_id!r} not in registry.\nProvide --project (and optionally --profile, --mode).")
profile = args.profile or "oreillyit-anthropic"
profiles_cfg = load_profiles()
mode = args.mode or profiles_cfg.get(profile, {}).get("default_mode", "quick")
path_map = build_path_map()
project_dir = next((p for p in path_map.values() if Path(p).name == args.project), None)
if not project_dir:
die(f"Project directory not found for '{args.project}'")
spawn_session(project_dir=project_dir, profile=profile, mode=mode,
resume_id=session_id, attach=args.attach)
def cmd_register(args) -> None:
"""Called by claude-tmux after spawning a session."""
registry = load_registry()
entry = {
"tmux_name": args.tmux_name,
"project": Path(args.project_dir).name,
"project_dir": args.project_dir,
"profile": args.profile,
"mode": args.mode,
"machine": HOSTNAME,
"spawned_at": datetime.now(timezone.utc).isoformat(),
"session_id": None,
"last_activity": None,
}
# Remove any prior entry for this tmux session name
registry["sessions"] = [
s for s in registry.get("sessions", [])
if s.get("tmux_name") != args.tmux_name
]
registry["sessions"].append(entry)
save_registry(registry)
def cmd_gc(args) -> None:
registry = load_registry()
profiles = load_profiles()
live = list_tmux_sessions()
cutoff = time.time() - args.days * 86400
# Backfill session IDs first
backfilled = backfill_session_ids(registry, profiles)
if backfilled:
print(f"Backfilled {backfilled} session ID(s).")
before = len(registry.get("sessions", []))
def keep(s: dict) -> bool:
if s.get("tmux_name") in live:
return True
spawned = s.get("spawned_at", "")
if spawned:
try:
ts = datetime.fromisoformat(spawned).timestamp()
return ts > cutoff
except ValueError:
pass
return False
registry["sessions"] = [s for s in registry.get("sessions", []) if keep(s)]
after = len(registry["sessions"])
save_registry(registry)
print(f"Pruned {before - after} stale entries ({after} remaining).")
# ── Interactive menu ─────────────────────────────────────────────────────────
def cmd_menu(_args=None) -> None:
"""Interactive session picker. Shown when claude-sessions is invoked with no args."""
profiles_cfg = load_profiles()
while True:
live = sorted(list_tmux_sessions())
registry = load_registry()
resumable = [
s for s in registry.get("sessions", [])
if s.get("tmux_name") not in live and s.get("session_id")
]
# Build numbered options
options: list[tuple[str, dict]] = [] # (kind, data)
for name in live:
options.append(("live", {"tmux_name": name}))
for entry in resumable:
options.append(("resumable", entry))
# ── Print menu ──
os.system("clear 2>/dev/null || true")
print(f"\n{BOLD}Claude Sessions{NC}")
print("─" * 50)
if not options:
print(f"\n {DIM}No sessions.{NC}")
else:
reg_dirs = {e.get("tmux_name"): e.get("project_dir", "") for e in registry.get("sessions", [])}
tmux_dirs = live_session_paths()
resumable_names = {
id(d): Path(d.get("project_dir", "")).name or d.get("project", "?")
for k, d in options if k == "resumable"
}
name_w = min(36, max([len(d["tmux_name"]) for k, d in options if k == "live"]
+ [len(n) for n in resumable_names.values()] + [7]))
if live:
print(f"\n {BOLD}Live:{NC}")
print(f" {DIM}{'Session':<{name_w}} Location{NC}")
for idx, (kind, data) in enumerate(options, 1):
if kind != "live":
continue
n = data["tmux_name"]
loc = short_path(reg_dirs.get(n) or tmux_dirs.get(n, ""))
print(f" {CYAN}{idx:>2}){NC} {GREEN}●{NC} {n:<{name_w}} {DIM}{loc}{NC}")
if resumable:
print(f"\n {BOLD}Resumable:{NC}")
print(f" {DIM}{'Session':<{name_w}} {'Location':<40} Details{NC}")
for idx, (kind, data) in enumerate(options, 1):
if kind != "resumable":
continue
proj = resumable_names[id(data)]
sid = (data.get("session_id") or "")[:8]
ts = (data.get("last_activity") or data.get("spawned_at") or "?")[:16]
profile = data.get("profile", "?")
loc = short_path(data.get("project_dir", ""))
print(f" {CYAN}{idx:>2}){NC} {DIM}○{NC} {proj:<{name_w}} "
f"{loc:<40} {DIM}{profile} · {sid} · {ts}{NC}")
print(f"\n {CYAN} r){NC} Recover sessions from restart (--days 7)")
print(f" {CYAN} n){NC} New session")
print(f" {CYAN} q){NC} Quit")
print()
try:
raw = input(" Choice: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
break
if raw == "q":
break
elif raw == "r":
import types
a = types.SimpleNamespace(days=7, mode=None, attach=False)
cmd_recover(a)
input("\nPress Enter to return to menu…")
elif raw == "n":
default_dir = find_claude_md_dir(Path.cwd())
try:
if default_dir:
print(f" Found CLAUDE.md in {BOLD}{default_dir}{NC}")
ans = input(
f" Folder [{BOLD}{short_path(str(default_dir))}{NC}] "
f"(Enter = use this, b = browse, or type a project/path): "
).strip() or str(default_dir)
else:
ans = input(" Folder (b = browse, or type a project/path): ").strip()
except (KeyboardInterrupt, EOFError):
print()
continue
if not ans:
continue
if ans.lower() == "b":
folder = pick_folder(default_dir or Path.cwd())
if folder is None:
continue
print(f" Folder: {BOLD}{short_path(str(folder))}{NC}")
else:
folder = resolve_project_dir(ans)
if folder is None:
print(f" {YELLOW}Folder not found: {ans}{NC}")
time.sleep(1.5)
continue
topic = prompt_topic(folder, list_tmux_sessions())
if topic is None:
continue
import types
a = types.SimpleNamespace(project=str(folder), mode=None, profile=None, name=topic, attach=True)
print()
cmd_spawn(a) # exec's claude-tmux --attach; replaces this process
elif raw.isdigit():
idx = int(raw) - 1
if idx < 0 or idx >= len(options):
print(f" {YELLOW}Invalid choice{NC}")
time.sleep(1)
continue
kind, data = options[idx]
if kind == "live":
name = data["tmux_name"]
print(f"\n Attaching to {BOLD}{name}{NC}…")
if os.environ.get("TMUX"):
subprocess.run(["tmux", "switch-client", "-t", f"={name}"])
else:
os.execvp("tmux", ["tmux", "attach", "-t", f"={name}"])
break # exec'd — this line only reached on switch-client
elif kind == "resumable":
project_dir = data.get("project_dir", "")
profile = data.get("profile", "oreillyit-anthropic")
mode = profiles_cfg.get(profile, {}).get("default_mode", "quick")
session_id = data.get("session_id", "")
if not project_dir:
print(f" {YELLOW}Project directory unknown — cannot resume{NC}")
time.sleep(1)
continue
print()
spawn_session(project_dir=project_dir, profile=profile,
mode=mode, resume_id=session_id, attach=True)
else:
print(f" {YELLOW}Unknown choice: {raw!r}{NC}")
time.sleep(1)
# ── Entrypoint ───────────────────────────────────────────────────────────────
def main() -> None:
p = argparse.ArgumentParser(
prog="claude-sessions",
description="Session lifecycle manager for Claude Code tmux sessions",
add_help=True,
)
sub = p.add_subparsers(dest="command", metavar="COMMAND")
sub.add_parser("list", help="Show live and resumable sessions")
sub.add_parser("menu", help="Interactive session menu (default when no args)")
rp = sub.add_parser("recover", help="Detect sessions active before a restart")
rp.add_argument("--days", type=int, default=7, help="Look back N days (default: 7)")
rp.add_argument("--mode", default=None)
rp.add_argument("--attach", action="store_true")
sp = sub.add_parser("spawn", help="Spawn a new session (wraps claude-tmux)")
sp.add_argument("project", help="Project name or path")
sp.add_argument("--mode", default=None)
sp.add_argument("--profile", default=None)
sp.add_argument("--name", default=None)
sp.add_argument("--attach", action="store_true")
sp.add_argument("--headless", action="store_true",
help="Non-interactive: never prompt or attach; fail (exit 2) if the topic is in use")
sp.add_argument("--create", action="store_true",
help="With --headless: create the folder (absolute path) if it does not exist")
rsp = sub.add_parser("resume", help="Resume a past conversation by session ID")
rsp.add_argument("session_id", help="Session UUID or prefix")
rsp.add_argument("--project", default=None)
rsp.add_argument("--profile", default=None)
rsp.add_argument("--mode", default=None)
rsp.add_argument("--attach", action="store_true")
reg = sub.add_parser("register", help="Record a new session (called by claude-tmux)")
reg.add_argument("--project-dir", required=True)
reg.add_argument("--profile", required=True)
reg.add_argument("--mode", required=True)
reg.add_argument("--tmux-name", required=True)
gp = sub.add_parser("gc", help="Prune stale entries and backfill session IDs")
gp.add_argument("--days", type=int, default=30)
args = p.parse_args()
# No args + interactive TTY → menu
if not args.command:
if sys.stdin.isatty() and sys.stdout.isatty():
cmd_menu()
else:
p.print_help()
sys.exit(0)
{
"list": cmd_list,
"menu": cmd_menu,
"recover": cmd_recover,
"spawn": cmd_spawn,
"resume": cmd_resume,
"register": cmd_register,
"gc": cmd_gc,
}[args.command](args)
if __name__ == "__main__":
main()

143
scripts/claude-sessions-shutdown Executable file
View File

@@ -0,0 +1,143 @@
#!/usr/bin/env bash
# claude-sessions-shutdown — Gracefully exit all Claude Code tmux sessions.
#
# Called by the systemd user service before halt/reboot.
# Sends /exit-session to each Claude tmux session, waits for them to close,
# then force-kills any stragglers.
#
# Usage:
# claude-sessions-shutdown [--timeout N] # graceful timeout per session (default: 90)
# claude-sessions-shutdown --force # skip graceful exit, force-kill immediately
set -uo pipefail
REGISTRY="${HOME}/.local/share/claude-sessions/registry.json"
TIMEOUT=90
FORCE=0
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[0;33m'; NC='\033[0m'
log() { echo -e "$(date +%T) [claude-shutdown] $*"; }
warn() { echo -e "$(date +%T) [claude-shutdown] ${YELLOW}$*${NC}"; }
err() { echo -e "$(date +%T) [claude-shutdown] ${RED}$*${NC}" >&2; }
while [[ $# -gt 0 ]]; do
case "$1" in
--timeout) TIMEOUT="${2:-90}"; shift 2 ;;
--force) FORCE=1; shift ;;
*) err "Unknown flag: $1"; exit 1 ;;
esac
done
# ── Collect Claude sessions ──────────────────────────────────────────────────
collect_claude_sessions() {
local seen=()
local sessions=()
# Source 1: registry entries with a live tmux session
if [[ -f "$REGISTRY" ]]; then
while IFS= read -r name; do
[[ -n "$name" ]] || continue
if tmux has-session -t "=$name" 2>/dev/null; then
sessions+=("$name")
seen+=("$name")
fi
done < <(python3 -c "
import json, sys
try:
d = json.load(open('$REGISTRY'))
for s in d.get('sessions', []):
n = s.get('tmux_name', '')
if n: print(n)
except Exception: pass
" 2>/dev/null)
fi
# Source 2: tmux sessions whose pane runs a node/claude child process
while IFS= read -r line; do
session="${line%% *}"
pane_pid="${line##* }"
[[ -n "$session" && -n "$pane_pid" ]] || continue
# Already captured from registry?
skip=0
for s in "${seen[@]+"${seen[@]}"}"; do [[ "$s" == "$session" ]] && { skip=1; break; }; done
(( skip )) && continue
# Check if any child of the pane PID is node (Claude Code)
if pgrep -P "$pane_pid" -x node &>/dev/null 2>&1 || \
pgrep -a -P "$pane_pid" 2>/dev/null | grep -qE 'claude|\.claude'; then
sessions+=("$session")
fi
done < <(tmux list-panes -a -F '#{session_name} #{pane_pid}' 2>/dev/null)
printf '%s\n' "${sessions[@]+"${sessions[@]}"}"
}
# ── Main ─────────────────────────────────────────────────────────────────────
if ! command -v tmux &>/dev/null; then
log "tmux not found — nothing to do"
exit 0
fi
if ! tmux ls &>/dev/null 2>&1; then
log "No tmux server running — nothing to do"
exit 0
fi
mapfile -t claude_sessions < <(collect_claude_sessions)
if [[ ${#claude_sessions[@]} -eq 0 ]]; then
log "No Claude tmux sessions found — nothing to do"
exit 0
fi
log "Found ${#claude_sessions[@]} Claude session(s): ${claude_sessions[*]}"
if (( FORCE )); then
log "Force mode: killing sessions immediately"
for name in "${claude_sessions[@]}"; do
tmux kill-session -t "=$name" 2>/dev/null && log "Killed: $name" || warn "Could not kill: $name"
done
exit 0
fi
# Send /exit-session to each Claude session
for name in "${claude_sessions[@]}"; do
log "Sending /exit-session to: $name"
# Clear any partial input, then send the command
tmux send-keys -t "=$name" "" "" 2>/dev/null || true
tmux send-keys -t "=$name" -l "/exit-session" 2>/dev/null || true
tmux send-keys -t "=$name" Enter 2>/dev/null || true
done
# Wait for sessions to close
log "Waiting up to ${TIMEOUT}s for sessions to exit…"
deadline=$(( $(date +%s) + TIMEOUT ))
remaining=("${claude_sessions[@]}")
while [[ ${#remaining[@]} -gt 0 && $(date +%s) -lt $deadline ]]; do
still_open=()
for name in "${remaining[@]}"; do
if tmux has-session -t "=$name" 2>/dev/null; then
still_open+=("$name")
else
echo -e " ${GREEN}✓${NC} $name exited cleanly"
fi
done
remaining=("${still_open[@]+"${still_open[@]}"}")
[[ ${#remaining[@]} -gt 0 ]] && sleep 2
done
# Force-kill stragglers
if [[ ${#remaining[@]} -gt 0 ]]; then
warn "${#remaining[@]} session(s) did not exit gracefully — force-killing"
for name in "${remaining[@]}"; do
tmux kill-session -t "=$name" 2>/dev/null \
&& warn "Force-killed: $name" \
|| err "Failed to kill: $name"
done
fi
log "Shutdown complete"

51
scripts/install-systemd.sh Executable file
View File

@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# install-systemd.sh — Install claude-sessions systemd user service.
#
# Copies the service file from this repo into ~/.config/systemd/user/,
# reloads the daemon, and enables + starts the service.
#
# Safe to re-run (idempotent).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_DIR="$(dirname "$SCRIPT_DIR")"
SRC="$REPO_DIR/config/systemd/user/claude-sessions-save.service"
DST_DIR="$HOME/.config/systemd/user"
DST="$DST_DIR/claude-sessions-save.service"
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
ok() { echo -e "${GREEN}OK${NC} $*"; }
skip() { echo -e "${YELLOW}SKIP${NC} $*"; }
[[ -f "$SRC" ]] || { echo "ERROR: source not found: $SRC" >&2; exit 1; }
mkdir -p "$DST_DIR"
if [[ -f "$DST" ]] && diff -q "$SRC" "$DST" &>/dev/null; then
skip "service file already up to date"
else
cp "$SRC" "$DST"
ok "installed $DST"
fi
systemctl --user daemon-reload
ok "daemon reloaded"
if systemctl --user is-enabled --quiet claude-sessions-save.service 2>/dev/null; then
skip "already enabled"
else
systemctl --user enable claude-sessions-save.service
ok "enabled claude-sessions-save.service"
fi
if systemctl --user is-active --quiet claude-sessions-save.service 2>/dev/null; then
skip "already active"
else
systemctl --user start claude-sessions-save.service
ok "started claude-sessions-save.service"
fi
echo ""
echo "claude-sessions-save.service is enabled and active."
echo "It will gracefully exit Claude tmux sessions at shutdown."