Thomas Wolf Claude commited on
Commit
7112a46
·
unverified ·
1 Parent(s): 4782e4c

Sessions: let the pane say which conversation it is on (#23)

Browse files

PR #17 taught the re-pin watcher to follow /clear onto the successor
conversation, but it deliberately refuses in a folder shared by several live
claude sessions — a new transcript there cannot be attributed to a pane by
scanning. Folders like a busy project workspace ALWAYS have rivals, so /clear
there was never followed and a restart still resumed the pre-/clear thread.
Observed live: a pane /cleared on Aug 3, its successor absorbed a day of work,
and the Aug 4 restart brought back the abandoned thread and orphaned the rest.

Scanning cannot attribute, but the pane itself knows. A SessionStart hook
(scripts/am-repin-hook.sh, registered idempotently into settings.json at boot)
runs inside the pane's process tree, where $AM_ID names the pane and the
payload carries the new conversation's id. It drops a breadcrumb on local
disk; the watcher consumes it and re-pins with nothing to guess. The
shared-folder refusal now applies only to the scan fallback, which stays for
panes without a breadcrumb and for codex/opencode.

Breadcrumbs are trusted only after checks, because SessionStart fires more
widely than the docs admit (verified on 2.1.220 — print mode fires it too,
contradicting the documentation):

- $CLAUDE_PID must descend from the pane's tmux root. A nested `claude -p`
run inside a pane inherits $AM_ID and would otherwise claim the pane with a
throwaway conversation — worse than the bug. The hook's
CLAUDE_CODE_ENTRYPOINT=cli filter drops most of these; the pid check is the
backstop.
- The payload cwd must be the pane's folder, the id must not be pinned by
another session, and a no-matcher registration means source:"startup" also
replaces the "--session-id not honoured" heuristic with a fact, while
source:"resume" provably reports the unchanged id and stays a no-op.

Consumed-on-read so a stale crumb can never flip a pin backwards past a later
scan re-pin. Losing breadcrumbs at restart is harmless: the pin persists in
sessions.json and the relaunch's own resume event writes a fresh crumb —
which incidentally verifies the whole chain at every restart. The installer
merges into settings.json (never replaces — it also holds permissions and
model), is idempotent across boots, and refuses to touch a file it cannot
parse. Verified against a live config dir: adding hooks to an already-trusted
dir shows no trust prompt.

Co-authored-by: Claude <noreply@anthropic.com>

scripts/am-repin-hook.sh ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ # SessionStart breadcrumb for the manager's conversation re-pin (runner.js).
3
+ #
4
+ # Claude Code runs this inside the pane's process tree, so $AM_ID — set by the
5
+ # manager on the tmux session — says WHICH pane the new conversation belongs
6
+ # to. That attribution is the one thing the server cannot work out on its own
7
+ # when several claude panes share a folder, and it is why a /clear there could
8
+ # not be followed before (the folderIsShared refusal in runner.js).
9
+ #
10
+ # stdin is the hook payload: {session_id, transcript_path, cwd, source, ...}.
11
+ # $CLAUDE_PID is the claude process that fired the event; the server verifies
12
+ # it descends from the pane before trusting the breadcrumb, because nested
13
+ # runs (`claude -p` from inside a pane) inherit $AM_ID and would otherwise
14
+ # claim the pane with a throwaway conversation. The entrypoint check below
15
+ # already drops those non-interactive runs; the pid check covers the rest.
16
+ #
17
+ # Breadcrumbs live on LOCAL disk on purpose: losing them at a restart is
18
+ # harmless (the pin itself persists in sessions.json), and the relaunch's own
19
+ # source:"resume" event immediately writes a fresh one.
20
+ [ -n "$AM_ID" ] || exit 0
21
+ case "$AM_ID" in *[!a-zA-Z0-9_-]*) exit 0 ;; esac
22
+ [ "$CLAUDE_CODE_ENTRYPOINT" = "cli" ] || exit 0
23
+ case "$CLAUDE_PID" in '' | *[!0-9]*) CLAUDE_PID=0 ;; esac
24
+ d="${AM_REPIN_DIR:-/tmp/am-repin}"
25
+ mkdir -p "$d" 2>/dev/null || exit 0
26
+ {
27
+ printf '{"amId":"%s","claudePid":%d,"payload":' "$AM_ID" "$CLAUDE_PID"
28
+ cat
29
+ printf '}'
30
+ } > "$d/$AM_ID.json.tmp" 2>/dev/null && mv -f "$d/$AM_ID.json.tmp" "$d/$AM_ID.json"
31
+ exit 0
server/src/index.js CHANGED
@@ -16,7 +16,7 @@ import * as store from './sessions.js';
16
  import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
- import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError } from './runner.js';
20
 
21
  // Control frames ride the terminal socket behind a leading NUL pair, which real
22
  // PTY output never begins with. Same sentinel the old copy-mode hint used, so the
@@ -37,6 +37,10 @@ store.init();
37
  groups.init();
38
  order.init();
39
  demo.init();
 
 
 
 
40
 
41
  // One-time migration to the explicit-path model: sessions used to own a folder
42
  // named after them (renamed along with them), or inherit their group's shared
 
16
  import * as groups from './groups.js';
17
  import * as order from './order.js';
18
  import * as demo from './demo.js';
19
+ import { attach, agentInfo, deriveState, stop, stopAll, ensureRunning, sendInput, isRunning, capturePane, ghosttyReady, ghosttyError, installClaudeRepinHook } from './runner.js';
20
 
21
  // Control frames ride the terminal socket behind a leading NUL pair, which real
22
  // PTY output never begins with. Same sentinel the old copy-mode hint used, so the
 
37
  groups.init();
38
  order.init();
39
  demo.init();
40
+ // Claude panes report conversation resets (e.g. /clear) through a SessionStart
41
+ // hook, so the re-pin watcher can follow them even in shared folders where the
42
+ // transcript scan must refuse to guess. Non-fatal if it can't be installed.
43
+ installClaudeRepinHook();
44
 
45
  // One-time migration to the explicit-path model: sessions used to own a folder
46
  // named after them (renamed along with them), or inherit their group's shared
server/src/runner.js CHANGED
@@ -944,6 +944,111 @@ function folderIsShared(sessionId, workdir, cli) {
944
  // session forward onto whatever conversation Claude is actually writing. One
945
  // mechanism now covers both failure modes: the launch-time fallback (pin never
946
  // honoured) and a /clear at any later point.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
947
  function scheduleClaudeCapture(session, workdir) {
948
  // A relaunch restarts the watch with a fresh window, so `since` can't drift
949
  // older and start admitting pre-relaunch threads as candidates.
@@ -954,13 +1059,37 @@ function scheduleClaudeCapture(session, workdir) {
954
 
955
  const tick = () => {
956
  if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
957
  if (folderIsShared(session.id, workdir, 'claude')) {
958
  if (!warnedShared) {
959
  warnedShared = true;
960
- console.warn(`[claude] ${session.id}: folder shared with another live session — not following /clear here`);
961
  }
962
  } else {
963
- const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid;
964
  const hit = claudeCandidate(session.id, workdir, since);
965
  if (hit && hit.uuid !== pinned) {
966
  const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
 
944
  // session forward onto whatever conversation Claude is actually writing. One
945
  // mechanism now covers both failure modes: the launch-time fallback (pin never
946
  // honoured) and a /clear at any later point.
947
+
948
+ // ---------- breadcrumbs: the pane tells us, so we don't have to guess ----------
949
+ // The transcript scan above cannot attribute a new conversation when several
950
+ // live claude panes share a folder — folderIsShared refuses, and a /clear in
951
+ // such a folder was never followed. But the pane itself KNOWS: a SessionStart
952
+ // hook (installed into settings.json below, script at scripts/am-repin-hook.sh)
953
+ // runs inside the pane's process tree, where $AM_ID names the pane and the
954
+ // payload carries the new conversation's id. It drops that as a breadcrumb
955
+ // here; the watcher consumes it and re-pins with no guessing at all. The scan
956
+ // stays as the fallback for panes without a breadcrumb (hook newly installed,
957
+ // crumb lost) — and for codex/opencode, which have no hook mechanism.
958
+ const REPIN_DIR = process.env.AM_REPIN_DIR || '/tmp/am-repin';
959
+
960
+ // Read AND remove the pane's breadcrumb — consumed on read, so a stale crumb
961
+ // can never flip a pin backwards after a later, scan-based re-pin.
962
+ function takeClaudeBreadcrumb(sessionId) {
963
+ const p = path.join(REPIN_DIR, `${sessionId}.json`);
964
+ let raw;
965
+ try { raw = fs.readFileSync(p, 'utf8'); } catch { return null; }
966
+ try { fs.unlinkSync(p); } catch {}
967
+ try { return JSON.parse(raw); } catch { return null; }
968
+ }
969
+
970
+ // The pane's root process (what tmux spawned). After `exec claude` this IS
971
+ // claude; in the `claude --session-id … || exec claude` branch claude is a
972
+ // child of it. Either way the hook's $CLAUDE_PID must descend from it.
973
+ function paneRootPid(sessionId) {
974
+ try {
975
+ const out = execFileSync('tmux', ['list-panes', '-t', tmuxName(sessionId), '-F', '#{pane_pid}'],
976
+ { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: TERM_ENV });
977
+ const pid = parseInt(out.trim().split('\n')[0], 10);
978
+ return Number.isInteger(pid) && pid > 1 ? pid : null;
979
+ } catch { return null; }
980
+ }
981
+
982
+ // Walk /proc ppid links. comm in /proc/<pid>/stat may contain spaces and
983
+ // parens, so split after the LAST ') '.
984
+ function pidHasAncestor(pid, ancestor, readStat = (p) => fs.readFileSync(`/proc/${p}/stat`, 'utf8')) {
985
+ for (let p = pid, hops = 0; Number.isInteger(p) && p > 1 && hops < 64; hops++) {
986
+ if (p === ancestor) return true;
987
+ let stat;
988
+ try { stat = readStat(p); } catch { return false; }
989
+ const tail = stat.slice(stat.lastIndexOf(') ') + 2).split(' ');
990
+ p = parseInt(tail[1], 10); // state ppid …
991
+ }
992
+ return false;
993
+ }
994
+
995
+ // Pure verdict on one breadcrumb, exported for server/test/repin.test.mjs.
996
+ // `facts` carries everything environmental: { workdir, pinned, claimed (Set of
997
+ // uuids other sessions pin), pidTrusted (bool: claudePid descends from the
998
+ // pane) }. Returns { repin: uuid } or { repin: null, why }.
999
+ export function breadcrumbVerdict(crumb, sessionId, facts) {
1000
+ if (!crumb || typeof crumb !== 'object') return { repin: null, why: 'unreadable' };
1001
+ if (crumb.amId !== sessionId) return { repin: null, why: 'amId mismatch' };
1002
+ const uuid = crumb.payload?.session_id;
1003
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(uuid || ''))
1004
+ return { repin: null, why: 'no session_id' };
1005
+ // A crumb written before a pane was moved to another folder must not follow
1006
+ // it there — same folder-scoping rule the transcript scan applies.
1007
+ if (crumb.payload?.cwd !== facts.workdir) return { repin: null, why: 'cwd mismatch' };
1008
+ // Nested `claude -p` runs inherit $AM_ID and fire SessionStart too (verified
1009
+ // on 2.1.220 — the docs say -p skips hooks; it does not). The hook filters
1010
+ // on CLAUDE_CODE_ENTRYPOINT, and this is the backstop: only a process that
1011
+ // descends from the pane speaks for the pane.
1012
+ if (!facts.pidTrusted) return { repin: null, why: 'pid not in pane' };
1013
+ if (facts.claimed?.has(uuid)) return { repin: null, why: 'claimed by another session' };
1014
+ if (uuid === facts.pinned) return { repin: null, why: 'already pinned' };
1015
+ return { repin: uuid };
1016
+ }
1017
+
1018
+ // Register the SessionStart hook in $CLAUDE_CONFIG_DIR/settings.json. Merge,
1019
+ // never replace: the file also holds permissions/model/theme. Idempotent — a
1020
+ // second boot finds the entry and writes nothing. A file that exists but does
1021
+ // not parse is left alone (clobbering the user's settings to install a hook
1022
+ // would be a terrible trade), and every failure is non-fatal: without the
1023
+ // hook the watcher simply keeps today's behaviour.
1024
+ export function installClaudeRepinHook(hookCmd = '/app/scripts/am-repin-hook.sh') {
1025
+ const dir = process.env.CLAUDE_CONFIG_DIR;
1026
+ if (!dir) return false;
1027
+ const file = path.join(dir, 'settings.json');
1028
+ let cfg = {};
1029
+ try {
1030
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
1031
+ } catch (e) {
1032
+ if (e.code !== 'ENOENT') { console.warn(`[claude] not installing repin hook: ${file} unreadable (${e.message})`); return false; }
1033
+ }
1034
+ if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) { console.warn(`[claude] not installing repin hook: ${file} is not an object`); return false; }
1035
+ const entries = Array.isArray(cfg.hooks?.SessionStart) ? cfg.hooks.SessionStart : [];
1036
+ const present = entries.some((m) => (m?.hooks || []).some((h) => String(h?.command || '').includes('am-repin-hook.sh')));
1037
+ if (present) return true;
1038
+ // No matcher: fire for every source. `startup` replaces the "--session-id
1039
+ // not honoured" heuristic with a fact, `resume` is a proven no-op (same id),
1040
+ // and `clear` is the case this exists for. Filtering happens server-side.
1041
+ cfg.hooks = cfg.hooks || {};
1042
+ cfg.hooks.SessionStart = [...entries, { hooks: [{ type: 'command', command: hookCmd, timeout: 5 }] }];
1043
+ try {
1044
+ const tmp = `${file}.am-tmp`;
1045
+ fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n');
1046
+ fs.renameSync(tmp, file);
1047
+ console.warn(`[claude] repin hook installed in ${file}`);
1048
+ return true;
1049
+ } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; }
1050
+ }
1051
+
1052
  function scheduleClaudeCapture(session, workdir) {
1053
  // A relaunch restarts the watch with a fresh window, so `since` can't drift
1054
  // older and start admitting pre-relaunch threads as candidates.
 
1059
 
1060
  const tick = () => {
1061
  if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
1062
+ const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid;
1063
+
1064
+ // Breadcrumb first: the pane's own SessionStart hook told us which
1065
+ // conversation it is on, so no guessing — and no shared-folder refusal —
1066
+ // is needed. Consumed on read; a rejected crumb falls through to the scan.
1067
+ const crumb = takeClaudeBreadcrumb(session.id);
1068
+ if (crumb) {
1069
+ const root = paneRootPid(session.id);
1070
+ const verdict = breadcrumbVerdict(crumb, session.id, {
1071
+ workdir,
1072
+ pinned,
1073
+ claimed: new Set(list().filter((s) => s.id !== session.id && s.sessionUuid).map((s) => s.sessionUuid)),
1074
+ pidTrusted: !!root && pidHasAncestor(crumb.claudePid, root),
1075
+ });
1076
+ if (verdict.repin) {
1077
+ console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`);
1078
+ update(session.id, { sessionUuid: verdict.repin });
1079
+ const t = setTimeout(tick, REPIN_MS);
1080
+ if (t.unref) t.unref();
1081
+ claudeCapturing.set(session.id, t);
1082
+ return;
1083
+ }
1084
+ if (verdict.why !== 'already pinned') console.warn(`[claude] ${session.id}: breadcrumb rejected (${verdict.why})`);
1085
+ }
1086
+
1087
  if (folderIsShared(session.id, workdir, 'claude')) {
1088
  if (!warnedShared) {
1089
  warnedShared = true;
1090
+ console.warn(`[claude] ${session.id}: folder shared with another live session — following /clear only via breadcrumbs here`);
1091
  }
1092
  } else {
 
1093
  const hit = claudeCandidate(session.id, workdir, since);
1094
  if (hit && hit.uuid !== pinned) {
1095
  const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
server/test/repin.test.mjs CHANGED
@@ -76,6 +76,56 @@ check('claimed uuid skipped', runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid
76
  console.log('\nnothing new in the window means no re-pin');
77
  check('no candidate', runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
78
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
  console.log(`\n${pass} passed, ${fail} failed`);
80
  fs.rmSync(TMP, { recursive: true, force: true });
81
  process.exit(fail ? 1 : 0);
 
76
  console.log('\nnothing new in the window means no re-pin');
77
  check('no candidate', runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);
78
 
79
+ // ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
80
+ const E = 'eeeeeeee-0000-0000-0000-000000000005';
81
+ const crumb = (over = {}) => ({
82
+ amId: 's1', claudePid: 4242,
83
+ payload: { session_id: E, cwd: WORKDIR, source: 'clear' },
84
+ ...over,
85
+ });
86
+ const facts = (over = {}) => ({
87
+ workdir: WORKDIR, pinned: A, claimed: new Set([B]), pidTrusted: true, ...over,
88
+ });
89
+ const verdict = (c, f) => runner.breadcrumbVerdict(c, 's1', f);
90
+
91
+ console.log('\na trusted breadcrumb re-pins, no folder-sharing question asked');
92
+ check('clean crumb accepted', verdict(crumb(), facts()).repin, E);
93
+
94
+ console.log('\na breadcrumb only speaks for the pane that wrote it');
95
+ check('amId mismatch rejected', verdict(crumb({ amId: 's2' }), facts()).repin, null);
96
+ check('cwd mismatch rejected',
97
+ verdict(crumb({ payload: { session_id: E, cwd: '/elsewhere', source: 'clear' } }), facts()).repin, null);
98
+
99
+ console.log('\na nested claude -p cannot claim the pane (pid not under the pane root)');
100
+ check('untrusted pid rejected', verdict(crumb(), facts({ pidTrusted: false })).repin, null);
101
+ check('untrusted pid says why', verdict(crumb(), facts({ pidTrusted: false })).why, 'pid not in pane');
102
+
103
+ console.log('\nno-ops and garbage stay no-ops');
104
+ check('already-pinned crumb is a no-op',
105
+ verdict(crumb({ payload: { session_id: A, cwd: WORKDIR, source: 'resume' } }), facts()).why, 'already pinned');
106
+ check('claimed uuid left to its session',
107
+ verdict(crumb({ payload: { session_id: B, cwd: WORKDIR, source: 'clear' } }), facts()).why, 'claimed by another session');
108
+ check('malformed session_id rejected',
109
+ verdict(crumb({ payload: { session_id: 'not-a-uuid', cwd: WORKDIR } }), facts()).repin, null);
110
+ check('null crumb rejected', verdict(null, facts()).repin, null);
111
+
112
+ // ---------- hook installer: merge, never replace; idempotent; refuse corrupt ----------
113
+ console.log('\ninstaller merges into existing settings and is idempotent');
114
+ const settings = path.join(CFG, 'settings.json');
115
+ fs.writeFileSync(settings, JSON.stringify({ model: 'opus', permissions: { allow: ['Bash'] } }));
116
+ check('installs into existing file', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
117
+ let s = JSON.parse(fs.readFileSync(settings, 'utf8'));
118
+ check('other keys kept', s.model, 'opus');
119
+ check('hook entry present', s.hooks.SessionStart.length, 1);
120
+ check('second run is a no-op', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
121
+ s = JSON.parse(fs.readFileSync(settings, 'utf8'));
122
+ check('no duplicate entry', s.hooks.SessionStart.length, 1);
123
+
124
+ console.log('\ninstaller refuses to clobber a corrupt settings file');
125
+ fs.writeFileSync(settings, '{ not json');
126
+ check('corrupt file left alone', runner.installClaudeRepinHook('/x.sh'), false);
127
+ check('corrupt content untouched', fs.readFileSync(settings, 'utf8'), '{ not json');
128
+
129
  console.log(`\n${pass} passed, ${fail} failed`);
130
  fs.rmSync(TMP, { recursive: true, force: true });
131
  process.exit(fail ? 1 : 0);