Compare commits
15 Commits
wip/hooks-
...
f53c6d2980
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f53c6d2980 | ||
|
|
d23778942d | ||
|
|
c32ea467e4 | ||
|
|
97567b26cc | ||
|
|
ee9630a9e5 | ||
|
|
bbce7b2bf8 | ||
|
|
11ee50e9c7 | ||
|
|
6b4dea5ffb | ||
|
|
53170c320e | ||
|
|
2335a73a02 | ||
|
|
77a066edd2 | ||
|
|
36dfa13b02 | ||
|
|
2539915a5c | ||
|
|
59063ddfe9 | ||
|
|
52a76ba0e3 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,3 +6,6 @@
|
|||||||
# OS files
|
# OS files
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
|
||||||
|
# Agent worktrees — never committed.
|
||||||
|
.claude/
|
||||||
|
|||||||
15
CLAUDE.md
15
CLAUDE.md
@@ -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.
|
**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
|
## Session Start
|
||||||
|
|
||||||
**Before showing any picker, check for auto-selection signals:**
|
**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`
|
## 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
|
## Process Principles
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
Current work focus and active tasks. Used by `context-load` to orient agents at session start.
|
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
|
## Active
|
||||||
|
|
||||||
- [Context loading improvements](context/context-loading.md) — Testing and refining the `context-load` and `start-claude` scripts
|
- [Context loading improvements](context/context-loading.md) — Testing and refining the `context-load` and `start-claude` scripts
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
## Scripts
|
## 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
|
- [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
|
- [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
|
- [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](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
|
- [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
|
- [/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
|
## References
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ claude-foundations/
|
|||||||
set-topic.sh # Set per-session topic for the status line
|
set-topic.sh # Set per-session topic for the status line
|
||||||
extract-transcripts.py # Transcript backup tracking: register, list, extract, mark-processed
|
extract-transcripts.py # Transcript backup tracking: register, list, extract, mark-processed
|
||||||
list-transcripts-here.sh # Wrapper: extract-transcripts --list $(pwd) (skill bang-command safe)
|
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
|
context/ # Active work focus detail files
|
||||||
memory/ # Session logs and reflections
|
memory/ # Session logs and reflections
|
||||||
settings.yaml # Knowledge pipeline configuration
|
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/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/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/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 |
|
||||||
|
|||||||
18
config/systemd/user/claude-sessions-save.service
Normal file
18
config/systemd/user/claude-sessions-save.service
Normal 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
|
||||||
140
hooks/bash-pipeline-guard.sh
Executable file
140
hooks/bash-pipeline-guard.sh
Executable file
@@ -0,0 +1,140 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# PreToolUse hook for Bash — flags pipelines whose left side changes state but
|
||||||
|
# whose exit status is then consumed by `&&`, `||`, or an `if`-condition.
|
||||||
|
#
|
||||||
|
# bug or subtlety: `git push -q origin main 2>&1 | tail -1 && echo "pushed"`
|
||||||
|
# reads tail's exit status, not git's. A push that was rejected
|
||||||
|
# as non-fast-forward still reports "pushed".
|
||||||
|
#
|
||||||
|
# Contract:
|
||||||
|
# - exit 0 for anything it does not understand, and for any parse failure.
|
||||||
|
# A hook that breaks Bash is far worse than the bug it prevents.
|
||||||
|
# - exit 2 with a short stderr explanation when it sees the pattern.
|
||||||
|
# Advisory only — never block.
|
||||||
|
#
|
||||||
|
# Reads tool_input.command from stdin JSON (same shape post-edit-lint.sh parses).
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
INPUT="$(cat 2>/dev/null || true)"
|
||||||
|
[ -n "$INPUT" ] || exit 0
|
||||||
|
command -v python3 >/dev/null 2>&1 || exit 0
|
||||||
|
|
||||||
|
# Inline the matcher as a python script in a temp file. Single-quoted heredoc
|
||||||
|
# prevents shell expansion of $-characters in the python source.
|
||||||
|
TMPPY="$(mktemp -t bpg.XXXXXX 2>/dev/null || mktemp -t bpg.XXXXXX)" || exit 0
|
||||||
|
[ -n "$TMPPY" ] || exit 0
|
||||||
|
trap 'rm -f "$TMPPY"' EXIT INT TERM HUP
|
||||||
|
|
||||||
|
cat >"$TMPPY" <<'PYTHON_EOF'
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
raw = sys.stdin.read()
|
||||||
|
try:
|
||||||
|
data = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
# Parse failure → silent pass
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
tool_input = data.get("tool_input")
|
||||||
|
if not isinstance(tool_input, dict):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
cmd = tool_input.get("command", "")
|
||||||
|
if not isinstance(cmd, str) or not cmd:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_quotes(s):
|
||||||
|
# Strip double-quoted and single-quoted strings so we don't match a verb
|
||||||
|
# that lives inside a quoted argument or echo payload.
|
||||||
|
s = re.sub(r'"[^"]*"', "", s)
|
||||||
|
s = re.sub(r"'[^']*'", "", s)
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
|
# Author has handled the masked-status problem → no need to flag.
|
||||||
|
if re.search(r"set\s+-o\s+pipefail", cmd):
|
||||||
|
sys.exit(0)
|
||||||
|
if re.search(r"\$\{?PIPESTATUS", cmd):
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Walk the command once and record single-pipe positions, skipping `||`.
|
||||||
|
pipes = []
|
||||||
|
i = 0
|
||||||
|
while i < len(cmd):
|
||||||
|
c = cmd[i]
|
||||||
|
if c == "|" and i + 1 < len(cmd) and cmd[i + 1] == "|":
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
if c == "|":
|
||||||
|
pipes.append(i)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
if not pipes:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Left side = everything before the first pipe (the segment whose status we
|
||||||
|
# care about).
|
||||||
|
left = strip_quotes(cmd[: pipes[0]])
|
||||||
|
|
||||||
|
# State-changing verbs. Matched conservatively — these are the verbs the brief
|
||||||
|
# enumerates, matched as `verb` (not as a prefix).
|
||||||
|
state_verbs = [
|
||||||
|
(r"\bgit\s+push\b", "git push"),
|
||||||
|
(r"\bgit\s+commit\b", "git commit"),
|
||||||
|
(r"\bgit\s+merge\b", "git merge"),
|
||||||
|
(r"\bgit\s+rebase\b", "git rebase"),
|
||||||
|
(r"\bkubectl\s+apply\b", "kubectl apply"),
|
||||||
|
(r"\bkubectl\s+delete\b", "kubectl delete"),
|
||||||
|
(r"\bkubectl\s+patch\b", "kubectl patch"),
|
||||||
|
(r"\bdocker\s+push\b", "docker push"),
|
||||||
|
(r"\bhelm\s+upgrade\b", "helm upgrade"),
|
||||||
|
(r"\bhelm\s+install\b", "helm install"),
|
||||||
|
]
|
||||||
|
verb = None
|
||||||
|
for pat, name in state_verbs:
|
||||||
|
if re.search(pat, left):
|
||||||
|
verb = name
|
||||||
|
break
|
||||||
|
|
||||||
|
# curl is special: only state-changing when paired with a write method or a
|
||||||
|
# data payload. Read-only `curl ... | jq` must not be flagged.
|
||||||
|
if not verb and re.search(r"\bcurl\b", left):
|
||||||
|
if re.search(r"-X\s*(POST|PUT|DELETE)", left, re.IGNORECASE) or re.search(
|
||||||
|
r"--data", left
|
||||||
|
):
|
||||||
|
verb = "curl"
|
||||||
|
|
||||||
|
if not verb:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
# Is the pipeline's status consumed by `&&`, `||`, or an `if`-test whose
|
||||||
|
# `then` clause follows the pipeline?
|
||||||
|
rest = cmd[pipes[-1] + 1 :]
|
||||||
|
if re.search(r"&&|\|\|", rest):
|
||||||
|
print(verb)
|
||||||
|
sys.exit(0)
|
||||||
|
if re.search(r"\bthen\b", rest):
|
||||||
|
print(verb)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
sys.exit(0)
|
||||||
|
PYTHON_EOF
|
||||||
|
|
||||||
|
# Pipe the raw hook input through the python matcher; capture stdout (the verb
|
||||||
|
# name to surface in the advisory). Any non-zero exit from python (e.g. a real
|
||||||
|
# crash) is treated as a silent pass — same policy as a parse failure.
|
||||||
|
RESULT="$(printf '%s' "$INPUT" | python3 "$TMPPY" 2>/dev/null)" || exit 0
|
||||||
|
[ -n "$RESULT" ] || exit 0
|
||||||
|
|
||||||
|
{
|
||||||
|
printf 'Pipeline masks %s exit status: &&/|| reads the LAST command in the pipe, not the first. Use ${PIPESTATUS[0]} or drop the pipe.\n' \
|
||||||
|
"$RESULT"
|
||||||
|
} >&2
|
||||||
|
exit 2
|
||||||
338
hooks/tests/test-bash-pipeline-guard.sh
Executable file
338
hooks/tests/test-bash-pipeline-guard.sh
Executable file
@@ -0,0 +1,338 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Tests for hooks/bash-pipeline-guard.sh
|
||||||
|
#
|
||||||
|
# Convention: bash, `set -uo pipefail`, one PASS:/FAIL: per assertion,
|
||||||
|
# FAIL=1 aggregate, ALL_OK on full pass.
|
||||||
|
#
|
||||||
|
# Each test invokes the hook as a subprocess with real JSON on stdin — never
|
||||||
|
# source the hook, so we test its actual stdin/stdout/stderr contract.
|
||||||
|
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
PASS_COUNT=0
|
||||||
|
FAIL_COUNT=0
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
HOOK="$(cd "$(dirname "$0")/.." && pwd)/bash-pipeline-guard.sh"
|
||||||
|
[ -x "$HOOK" ] || {
|
||||||
|
echo "FATAL: hook not found or not executable: $HOOK" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
command -v python3 >/dev/null 2>&1 || {
|
||||||
|
echo "FATAL: python3 required to build test JSON" >&2
|
||||||
|
exit 2
|
||||||
|
}
|
||||||
|
|
||||||
|
pass() {
|
||||||
|
printf 'PASS: %s\n' "$1"
|
||||||
|
PASS_COUNT=$((PASS_COUNT + 1))
|
||||||
|
}
|
||||||
|
fail() {
|
||||||
|
printf 'FAIL: %s\n' "$1"
|
||||||
|
FAIL_COUNT=$((FAIL_COUNT + 1))
|
||||||
|
FAIL=1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build the JSON envelope the hook expects.
|
||||||
|
json_for_cmd() {
|
||||||
|
python3 -c '
|
||||||
|
import json, sys
|
||||||
|
print(json.dumps({"tool_input": {"command": sys.argv[1]}}))
|
||||||
|
' "$1"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Run the hook with the given stdin payload. Captures stderr to a file (so the
|
||||||
|
# test name stays readable on stdout) and stdout to nothing.
|
||||||
|
run_hook_with_stdin() {
|
||||||
|
local payload="$1"
|
||||||
|
local stderr_file
|
||||||
|
stderr_file="$(mktemp)"
|
||||||
|
printf '%s' "$payload" | "$HOOK" 2>"$stderr_file" >/dev/null
|
||||||
|
HOOK_RC=$?
|
||||||
|
HOOK_STDERR="$(cat "$stderr_file")"
|
||||||
|
rm -f "$stderr_file"
|
||||||
|
}
|
||||||
|
|
||||||
|
# assert_flag <label> <command>
|
||||||
|
# Hook must exit 2 and emit a non-empty stderr line.
|
||||||
|
assert_flag() {
|
||||||
|
local label="$1" cmd="$2"
|
||||||
|
run_hook_with_stdin "$(json_for_cmd "$cmd")"
|
||||||
|
if [ "$HOOK_RC" -eq 2 ] && [ -n "$HOOK_STDERR" ]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label (expected exit=2 + stderr; got exit=$HOOK_RC stderr='$HOOK_STDERR')"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# assert_pass <label> <command>
|
||||||
|
# Hook must exit 0 (no blocking). Stderr allowed to be non-empty — the brief
|
||||||
|
# says exit 0 for anything the hook doesn't understand, including cases where
|
||||||
|
# it might surface information; but in practice our hook is silent on pass.
|
||||||
|
assert_pass() {
|
||||||
|
local label="$1" cmd="$2"
|
||||||
|
run_hook_with_stdin "$(json_for_cmd "$cmd")"
|
||||||
|
if [ "$HOOK_RC" -eq 0 ]; then
|
||||||
|
pass "$label"
|
||||||
|
else
|
||||||
|
fail "$label (expected exit=0; got exit=$HOOK_RC stderr='$HOOK_STDERR')"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SHOULD FLAG — state-changing left side + pipe + && / || / if-test consumer
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
assert_flag "git push masked by tail && echo pushed (brief example)" \
|
||||||
|
'git push -q origin main 2>&1 | tail -1 && echo "pushed"'
|
||||||
|
|
||||||
|
assert_flag "git push simple mask" \
|
||||||
|
'git push origin main | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "git commit masked" \
|
||||||
|
'git commit -m msg file.txt | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "git merge masked" \
|
||||||
|
'git merge feature | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "git rebase masked" \
|
||||||
|
'git rebase main | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "kubectl apply masked" \
|
||||||
|
'kubectl apply -f foo.yaml | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "kubectl delete masked" \
|
||||||
|
'kubectl delete pod foo | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "kubectl patch masked" \
|
||||||
|
'kubectl patch svc foo -p {} | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "docker push masked" \
|
||||||
|
'docker push registry.example.com/img | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "helm upgrade masked" \
|
||||||
|
'helm upgrade foo bar | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "helm install masked" \
|
||||||
|
'helm install foo bar | head && echo done'
|
||||||
|
|
||||||
|
assert_flag "curl -X POST masked" \
|
||||||
|
'curl -X POST https://api.example.com | jq && echo done'
|
||||||
|
|
||||||
|
assert_flag "curl -X PUT masked" \
|
||||||
|
'curl -X PUT https://api.example.com | jq && echo done'
|
||||||
|
|
||||||
|
assert_flag "curl -X DELETE masked" \
|
||||||
|
'curl -X DELETE https://api.example.com | jq && echo done'
|
||||||
|
|
||||||
|
assert_flag "curl --data masked" \
|
||||||
|
"curl --data 'foo' https://api.example.com | jq && echo done"
|
||||||
|
|
||||||
|
assert_flag "git push consumed by ||" \
|
||||||
|
'git push | head || echo fallback'
|
||||||
|
|
||||||
|
assert_flag "multi-pipe state-changing left (git push | tee | tail)" \
|
||||||
|
'git push | tee /tmp/log | tail && echo done'
|
||||||
|
|
||||||
|
assert_flag "if-test consumes pipeline status (git push)" \
|
||||||
|
'if git push origin main | head; then echo done; fi'
|
||||||
|
|
||||||
|
assert_flag "if-test consumes pipeline status (kubectl apply)" \
|
||||||
|
'if kubectl apply -f foo.yaml | tee /tmp/log; then deploy; fi'
|
||||||
|
|
||||||
|
assert_flag "state-changing verb with quoted arg" \
|
||||||
|
'git push "origin main" | head && echo done'
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# SHOULD NOT FLAG — read-only, handled, or non-consuming contexts
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
assert_pass "no pipe, just &&" \
|
||||||
|
'git push origin main && echo done'
|
||||||
|
|
||||||
|
assert_pass "pipe with semicolon (no consumption)" \
|
||||||
|
'git push origin main | head -1; echo done'
|
||||||
|
|
||||||
|
assert_pass "set -o pipefail suppresses flag" \
|
||||||
|
'set -o pipefail; git push | head && echo done'
|
||||||
|
|
||||||
|
assert_pass "\${PIPESTATUS[0]} suppresses flag" \
|
||||||
|
'git push | head; echo ${PIPESTATUS[0]} && echo done'
|
||||||
|
|
||||||
|
assert_pass "git log is read-only" \
|
||||||
|
'git log | head && echo done'
|
||||||
|
|
||||||
|
assert_pass "ls is read-only" \
|
||||||
|
'ls | grep foo && echo done'
|
||||||
|
|
||||||
|
assert_pass "cat is read-only" \
|
||||||
|
'cat file | grep foo && echo done'
|
||||||
|
|
||||||
|
assert_pass "grep is read-only" \
|
||||||
|
'grep foo file | head && echo done'
|
||||||
|
|
||||||
|
assert_pass "curl without write method" \
|
||||||
|
'curl https://api.example.com | jq && echo done'
|
||||||
|
|
||||||
|
assert_pass "git status is read-only" \
|
||||||
|
'git status | head && echo done'
|
||||||
|
|
||||||
|
assert_pass "state verb quoted (echo \"git push\")" \
|
||||||
|
'echo "git push" | head && echo done'
|
||||||
|
|
||||||
|
assert_pass "then keyword is in if-body, not after pipe" \
|
||||||
|
'if [ -f foo ]; then git push | head; fi'
|
||||||
|
|
||||||
|
# Malformed JSON, empty input, command with quotes and newlines
|
||||||
|
assert_pass "malformed JSON does not crash" \
|
||||||
|
'not json {{{'
|
||||||
|
|
||||||
|
assert_pass "empty JSON object does not crash" \
|
||||||
|
'{}'
|
||||||
|
|
||||||
|
assert_pass "missing tool_input does not crash" \
|
||||||
|
'{"foo":"bar"}'
|
||||||
|
|
||||||
|
assert_pass "tool_input as non-dict does not crash" \
|
||||||
|
'{"tool_input":"oops"}'
|
||||||
|
|
||||||
|
assert_pass "empty stdin does not crash" \
|
||||||
|
''
|
||||||
|
|
||||||
|
# Command containing quotes and newlines. State verb is inside an echo — must
|
||||||
|
# not flag. The newlines are encoded as literal "\n" in JSON; the hook sees a
|
||||||
|
# real newline.
|
||||||
|
assert_pass "command with embedded newline (state verb quoted)" \
|
||||||
|
$'echo "git push" | head && echo done'
|
||||||
|
|
||||||
|
# A real multi-line state-changing push that the hook should still flag.
|
||||||
|
assert_flag "real state-changing push spanning newlines" \
|
||||||
|
$'git push \\\n | head && echo done'
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Mutation tests — prove the suite can catch real regressions
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Each mutation rewrites one piece of the matcher in a temp copy of the hook,
|
||||||
|
# runs a previously-passing case through it, and expects the hook to either
|
||||||
|
# fail to flag (when we broke detection of a flag case) or unexpectedly flag
|
||||||
|
# (when we broke detection of a pass case). A mutation that does not change
|
||||||
|
# the outcome is a test that cannot fail.
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "--- Mutation tests ---"
|
||||||
|
|
||||||
|
# Apply a string substitution to a copy of the hook. Use python so we don't
|
||||||
|
# fight BRE/ERE escaping.
|
||||||
|
apply_mutation() {
|
||||||
|
local hook_src="$1" mutation="$2" out="$3"
|
||||||
|
python3 -c '
|
||||||
|
import sys
|
||||||
|
old, new = sys.argv[1].split("=>", 1)
|
||||||
|
with open(sys.argv[2]) as f:
|
||||||
|
src = f.read()
|
||||||
|
if old not in src:
|
||||||
|
sys.stderr.write("mutation pattern not found: " + repr(old) + "\n")
|
||||||
|
sys.exit(1)
|
||||||
|
with open(sys.argv[3], "w") as f:
|
||||||
|
f.write(src.replace(old, new, 1))
|
||||||
|
' "$mutation" "$hook_src" "$out"
|
||||||
|
}
|
||||||
|
|
||||||
|
# verify_mutation <name> <mutation> <cmd> <expected>
|
||||||
|
# expected = "flag" → the broken hook must no longer flag this should-flag case
|
||||||
|
# expected = "no-flag" → the broken hook must no longer crash or block a pass case
|
||||||
|
verify_mutation() {
|
||||||
|
local name="$1" mutation="$2" cmd="$3" expected="$4"
|
||||||
|
local broken
|
||||||
|
broken="$(mktemp -t bpgmut.XXXXXX)" || {
|
||||||
|
fail "mutation $name: mktemp failed"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ! apply_mutation "$HOOK" "$mutation" "$broken"; then
|
||||||
|
fail "mutation $name: could not apply"
|
||||||
|
rm -f "$broken"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
chmod +x "$broken"
|
||||||
|
local stderr_file rc stderr_content
|
||||||
|
stderr_file="$(mktemp)"
|
||||||
|
json_for_cmd "$cmd" | "$broken" 2>"$stderr_file" >/dev/null
|
||||||
|
rc=$?
|
||||||
|
stderr_content="$(cat "$stderr_file")"
|
||||||
|
rm -f "$stderr_file" "$broken"
|
||||||
|
|
||||||
|
case "$expected" in
|
||||||
|
flag)
|
||||||
|
if [ "$rc" -eq 2 ]; then
|
||||||
|
fail "mutation $name: still flags (rc=$rc) — test did NOT go red"
|
||||||
|
else
|
||||||
|
pass "mutation $name: no longer flags (rc=$rc) — test went red"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
no-flag)
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
fail "mutation $name: pass-case still passes (rc=$rc) — test did NOT go red"
|
||||||
|
else
|
||||||
|
pass "mutation $name: pass-case now fails (rc=$rc stderr=$stderr_content) — test went red"
|
||||||
|
fi
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
MUTATION_OK=1
|
||||||
|
|
||||||
|
# Mutation A: kill the git-push pattern. The flag-case must stop being flagged.
|
||||||
|
verify_mutation "git-push pattern" \
|
||||||
|
'r"\bgit\s+push\b"=>r"\bgit\s+pull\b"' \
|
||||||
|
'git push -q origin main 2>&1 | tail -1 && echo "pushed"' \
|
||||||
|
"flag"
|
||||||
|
|
||||||
|
# Mutation B: never consume \|| when present. The flag-case with && stays flagged,
|
||||||
|
# so instead break the && check.
|
||||||
|
verify_mutation "and-or consumption check" \
|
||||||
|
're.search(r"&&|\|\|", rest)=>re.search(r"XXX", rest)' \
|
||||||
|
'git push | head && echo done' \
|
||||||
|
"flag"
|
||||||
|
|
||||||
|
# Mutation C: treat every `|` as a `||` and skip it, so the parser thinks
|
||||||
|
# the command has no single pipe and never flags.
|
||||||
|
verify_mutation "single-pipe detection" \
|
||||||
|
'if c == "|" and i + 1 < len(cmd) and cmd[i + 1] == "|":=>if c == "|" or i + 1 < len(cmd) and cmd[i + 1] == "|":' \
|
||||||
|
'git push -q origin main 2>&1 | tail -1 && echo "pushed"' \
|
||||||
|
"flag"
|
||||||
|
|
||||||
|
# Mutation D: drop the curl-with-write branch so a state-changing curl stops
|
||||||
|
# being flagged.
|
||||||
|
verify_mutation "curl write-method detection" \
|
||||||
|
'verb = "curl"=>verb = "WEB' \
|
||||||
|
'curl -X POST https://api.example.com | jq && echo done' \
|
||||||
|
"flag"
|
||||||
|
|
||||||
|
# Mutation E: kill the `then` check so the if-context case stops being flagged.
|
||||||
|
verify_mutation "then-keyword check" \
|
||||||
|
're.search(r"\bthen\b", rest)=>re.search(r"XXXXX", rest)' \
|
||||||
|
'if git push origin main | head; then echo done; fi' \
|
||||||
|
"flag"
|
||||||
|
|
||||||
|
# Mutation F: kill the PIPESTATUS bailout so a should-pass command with
|
||||||
|
# PIPESTATUS now incorrectly flags.
|
||||||
|
verify_mutation "PIPESTATUS bailout" \
|
||||||
|
're.search(r"\$\{?PIPESTATUS", cmd)=>re.search(r"\$\{?XXXXXXXX", cmd)' \
|
||||||
|
'git push | head; echo ${PIPESTATUS[0]} && echo done' \
|
||||||
|
"no-flag"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
# Summary
|
||||||
|
# -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "Passed: $PASS_COUNT"
|
||||||
|
echo "Failed: $FAIL_COUNT"
|
||||||
|
|
||||||
|
if [ "$FAIL" -eq 0 ]; then
|
||||||
|
echo "ALL_OK"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
24
memory/log/2026-10-12.162308.md
Normal file
24
memory/log/2026-10-12.162308.md
Normal 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)
|
||||||
17
memory/log/2026-10-13.100156.md
Normal file
17
memory/log/2026-10-13.100156.md
Normal 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.
|
||||||
27
memory/log/2026-10-26.003911.md
Normal file
27
memory/log/2026-10-26.003911.md
Normal 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.
|
||||||
26
memory/script-claude-sessions.md
Normal file
26
memory/script-claude-sessions.md
Normal 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.
|
||||||
@@ -12,14 +12,14 @@ from the Claude app.
|
|||||||
|
|
||||||
- `/spawn-session <project>` — spawn for a named project folder
|
- `/spawn-session <project>` — spawn for a named project folder
|
||||||
- `/spawn-session <project> --mode <mode>` — override engagement mode
|
- `/spawn-session <project> --mode <mode>` — override engagement mode
|
||||||
- `/spawn-session <project> --attach` — attach after spawning
|
|
||||||
- `/spawn-session` — lists projects and asks which one
|
- `/spawn-session` — lists projects and asks which one
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
Thin wrapper over the `claude-tmux` script (in `small-scripts`, symlinked to `~/sbin`).
|
Calls `claude-sessions spawn <folder> --headless [--name topic] [--create]` (since 2026-10-26),
|
||||||
The skill only resolves the project name from `$ARGUMENTS` (or asks) and shells out to
|
which wraps `claude-tmux` (in `small-scripts`, symlinked to `~/sbin`). The skill only resolves
|
||||||
`claude-tmux <project> [flags]`. All real logic — folder resolution, mode selection
|
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,
|
(profile `last-mode` unless `--mode`), Title-Casing, ` #N` auto-increment, tmux creation,
|
||||||
and launching `claude --remote-control` via `claude-profile` — lives in the script.
|
and launching `claude --remote-control` via `claude-profile` — lives in the script.
|
||||||
|
|
||||||
|
|||||||
24
memory/skill-supervise.md
Normal file
24
memory/skill-supervise.md
Normal 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
991
scripts/claude-sessions
Executable 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
143
scripts/claude-sessions-shutdown
Executable 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
51
scripts/install-systemd.sh
Executable 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."
|
||||||
Reference in New Issue
Block a user