Claude Claude Opus 5 commited on
Commit
9b8a289
·
1 Parent(s): 1bb8739

Repin: fix the pane root, then stop scanning the bucket every 20s

Browse files

Two changes to the claude re-pin watcher, the second only effective
because of the first.

1. paneRootPid() still asked tmux for #{pane_pid}. Replacing tmux with a
server-held libghostty grid removed both identifiers that call used
(tmuxName, execFileSync) but left the call itself, so it threw a
ReferenceError into its own bare catch on every tick and returned
null. A null pane root makes pidTrusted false for every breadcrumb,
so the SessionStart hook path added in #23 was silently dead — the
only breadcrumb outcome in the Space logs was "breadcrumb rejected
(pid not in pane)", and /clear in a shared folder was still never
followed. The server owns the PTY now, so the pane root is a property
read with no subprocess per tick, and it carries the same semantic as
tmux's pane_pid.

2. With breadcrumbs working, the transcript scan is a backstop rather
than the mechanism, so it steps down from REPIN_MS to once every 10
minutes for any pane whose hook has proven itself. The scan is a
readdirSync per project dir plus a statSync per transcript, and
CLAUDE_CONFIG_DIR is on the FUSE bucket: measured on the Space, ~3ms
warm and 1.1-1.3s cold, synchronously blocking the one event loop
that also carries every session's PTY. At the 20s beat, once per live
pane, that was a terminal freeze every ~20 seconds.

A pane with no working hook keeps the old cadence, so nothing regresses
where the scan is still the only mechanism.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Files changed (2) hide show
  1. server/src/runner.js +57 -11
  2. server/test/repin.test.mjs +42 -0
server/src/runner.js CHANGED
@@ -976,16 +976,23 @@ function takeClaudeBreadcrumb(sessionId) {
976
  try { return JSON.parse(raw); } catch { return null; }
977
  }
978
 
979
- // The pane's root process (what tmux spawned). After `exec claude` this IS
980
- // claude; in the `claude --session-id … || exec claude` branch claude is a
981
- // child of it. Either way the hook's $CLAUDE_PID must descend from it.
982
- function paneRootPid(sessionId) {
983
- try {
984
- const out = execFileSync('tmux', ['list-panes', '-t', tmuxName(sessionId), '-F', '#{pane_pid}'],
985
- { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], env: TERM_ENV });
986
- const pid = parseInt(out.trim().split('\n')[0], 10);
987
- return Number.isInteger(pid) && pid > 1 ? pid : null;
988
- } catch { return null; }
 
 
 
 
 
 
 
989
  }
990
 
991
  // Walk /proc ppid links. comm in /proc/<pid>/stat may contain spaces and
@@ -1058,6 +1065,34 @@ export function installClaudeRepinHook(hookCmd = '/app/scripts/am-repin-hook.sh'
1058
  } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; }
1059
  }
1060
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1061
  function scheduleClaudeCapture(session, workdir) {
1062
  // A relaunch restarts the watch with a fresh window, so `since` can't drift
1063
  // older and start admitting pre-relaunch threads as candidates.
@@ -1065,6 +1100,10 @@ function scheduleClaudeCapture(session, workdir) {
1065
  if (prev) clearTimeout(prev);
1066
  const since = Date.now() - 2000;
1067
  let warnedShared = false;
 
 
 
 
1068
 
1069
  const tick = () => {
1070
  if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
@@ -1082,6 +1121,12 @@ function scheduleClaudeCapture(session, workdir) {
1082
  claimed: new Set(list().filter((s) => s.id !== session.id && s.sessionUuid).map((s) => s.sessionUuid)),
1083
  pidTrusted: !!root && pidHasAncestor(crumb.claudePid, root),
1084
  });
 
 
 
 
 
 
1085
  if (verdict.repin) {
1086
  console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`);
1087
  update(session.id, { sessionUuid: verdict.repin });
@@ -1098,7 +1143,8 @@ function scheduleClaudeCapture(session, workdir) {
1098
  warnedShared = true;
1099
  console.warn(`[claude] ${session.id}: folder shared with another live session — following /clear only via breadcrumbs here`);
1100
  }
1101
- } else {
 
1102
  const hit = claudeCandidate(session.id, workdir, since);
1103
  if (hit && hit.uuid !== pinned) {
1104
  const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
 
976
  try { return JSON.parse(raw); } catch { return null; }
977
  }
978
 
979
+ // The pane's root process the PTY this server spawned for the session. After
980
+ // `exec claude` this IS claude; in the `claude --session-id … || exec claude`
981
+ // branch claude is a child of it. Either way the hook's $CLAUDE_PID must descend
982
+ // from it.
983
+ //
984
+ // This used to ask tmux for `#{pane_pid}`. Replacing tmux with a server-held grid
985
+ // left the call behind referencing two identifiers that no longer exist here
986
+ // (`tmuxName`, `execFileSync`), so it threw a ReferenceError into its own bare
987
+ // `catch` on every tick and returned null. That failed EVERY breadcrumb as 'pid
988
+ // not in pane' and silently disabled the whole hook path — observed live: the
989
+ // only breadcrumb outcome in the Space logs was `breadcrumb rejected (pid not in
990
+ // pane)`. The server owns the PTY now, so the pane root is a property read and
991
+ // there is no subprocess per tick either.
992
+ // Exported for server/test/repin.test.mjs.
993
+ export function paneRootPid(sessionId) {
994
+ const pid = hosts.get(sessionId)?.pty?.pid;
995
+ return Number.isInteger(pid) && pid > 1 ? pid : null;
996
  }
997
 
998
  // Walk /proc ppid links. comm in /proc/<pid>/stat may contain spaces and
 
1065
  } catch (e) { console.warn(`[claude] repin hook install failed: ${e.message}`); return false; }
1066
  }
1067
 
1068
+ // Once the pane's SessionStart hook has proven itself, the transcript scan is a
1069
+ // backstop rather than the mechanism, and it runs on this cadence instead of
1070
+ // REPIN_MS. The scan is the expensive half of a tick and the breadcrumb is the
1071
+ // authoritative one: `claudeTranscriptsSince` is a readdirSync per project dir
1072
+ // plus a statSync per transcript, and on the Space those land on the FUSE bucket
1073
+ // (CLAUDE_CONFIG_DIR is under /data). Measured there: ~3ms when the mount's
1074
+ // attribute cache is warm, 1.1-1.3s when it is cold — a synchronous block of the
1075
+ // one event loop that also carries every session's PTY. At the REPIN_MS beat,
1076
+ // once per live pane, that was a terminal freeze roughly every 20 seconds.
1077
+ //
1078
+ // This is the window in which a pin can be stale if a breadcrumb is ever LOST
1079
+ // (the hook fired but its crumb never reached us). The breadcrumb path itself
1080
+ // stays on the REPIN_MS beat — it reads one file on local disk and costs nothing.
1081
+ const SCAN_BACKSTOP_MS = 10 * 60_000;
1082
+
1083
+ /**
1084
+ * Should this tick run the transcript scan?
1085
+ *
1086
+ * Before the hook has proven itself the cadence is unchanged, so a pane with no
1087
+ * working hook (hook newly installed, crumb lost, a harness with no hook at all)
1088
+ * keeps the pre-existing behaviour and nothing regresses. Pure so the cadence is
1089
+ * testable without a live pane; exported for server/test/repin.test.mjs.
1090
+ */
1091
+ export function claudeScanDue({ hookProven, lastScanAt }, now = Date.now()) {
1092
+ if (!hookProven) return true;
1093
+ return now - (lastScanAt || 0) >= SCAN_BACKSTOP_MS;
1094
+ }
1095
+
1096
  function scheduleClaudeCapture(session, workdir) {
1097
  // A relaunch restarts the watch with a fresh window, so `since` can't drift
1098
  // older and start admitting pre-relaunch threads as candidates.
 
1100
  if (prev) clearTimeout(prev);
1101
  const since = Date.now() - 2000;
1102
  let warnedShared = false;
1103
+ // Has a breadcrumb from this pane ever named this pane's OWN conversation? That
1104
+ // is what demotes the scan to a backstop — see claudeScanDue.
1105
+ let hookProven = false;
1106
+ let lastScanAt = 0;
1107
 
1108
  const tick = () => {
1109
  if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
 
1121
  claimed: new Set(list().filter((s) => s.id !== session.id && s.sessionUuid).map((s) => s.sessionUuid)),
1122
  pidTrusted: !!root && pidHasAncestor(crumb.claudePid, root),
1123
  });
1124
+ // A crumb that named this pane's own conversation — whether it moved the
1125
+ // pin or was already on it (the `resume` no-op) — proves the hook fires
1126
+ // here, so the scan can step down to a backstop. A crumb rejected for any
1127
+ // other reason proves nothing about this pane: 'pid not in pane' is a
1128
+ // nested `claude -p`, 'cwd mismatch' is someone else's run.
1129
+ if (verdict.repin || verdict.why === 'already pinned') hookProven = true;
1130
  if (verdict.repin) {
1131
  console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${verdict.repin} (breadcrumb, source: ${crumb.payload?.source || '?'})`);
1132
  update(session.id, { sessionUuid: verdict.repin });
 
1143
  warnedShared = true;
1144
  console.warn(`[claude] ${session.id}: folder shared with another live session — following /clear only via breadcrumbs here`);
1145
  }
1146
+ } else if (claudeScanDue({ hookProven, lastScanAt })) {
1147
+ lastScanAt = Date.now();
1148
  const hit = claudeCandidate(session.id, workdir, since);
1149
  if (hit && hit.uuid !== pinned) {
1150
  const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
server/test/repin.test.mjs CHANGED
@@ -109,6 +109,48 @@ 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');
 
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
+ // ---------- the pane root: the fact every breadcrumb is trusted against ----------
113
+ // paneRootPid used to shell out to `tmux list-panes`. The libghostty migration
114
+ // removed tmux but left the call, referencing identifiers that no longer exist —
115
+ // so it threw into its own bare catch, returned null, and every breadcrumb was
116
+ // rejected as 'pid not in pane'. A null pane root disables the hook path wholesale,
117
+ // so this asserts against a REAL running session rather than a mock.
118
+ console.log('\nthe pane root is the session PTY the server holds');
119
+ check('unknown session has no pane root', runner.paneRootPid('nope'), null);
120
+ const live = sessions.create({ name: 'live', cli: 'shell', path: 'proj-a' });
121
+ let liveRoot = null;
122
+ try {
123
+ runner.ensureRunning(live, 80, 24);
124
+ liveRoot = runner.paneRootPid(live.id);
125
+ check('live session has a pane root', Number.isInteger(liveRoot) && liveRoot > 1, true);
126
+ check('the pane root is a live process', fs.existsSync(`/proc/${liveRoot}`), true);
127
+ } finally {
128
+ runner.stop(live.id);
129
+ }
130
+
131
+ // ---------- scan cadence: the breadcrumb is the mechanism, the scan a backstop ----------
132
+ // The scan is a readdirSync + a statSync per transcript, and CLAUDE_CONFIG_DIR is
133
+ // on the FUSE bucket on the Space: ~1.2s of blocked event loop whenever the
134
+ // mount's attribute cache is cold. At the REPIN_MS beat that froze the terminal
135
+ // every ~20s per live pane, so once the hook has proven itself it steps down.
136
+ const MINUTE = 60_000;
137
+ console.log('\nwithout a proven hook the scan runs every tick (unchanged)');
138
+ check('no proof: due immediately', runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW), true);
139
+ check('no proof: still due 20s later',
140
+ runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW + 20_000), true);
141
+
142
+ console.log('\na proven hook steps the scan down to a backstop cadence');
143
+ check('proven: not due on the next beat',
144
+ runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 20_000), false);
145
+ check('proven: not due after 9 minutes',
146
+ runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 9 * MINUTE), false);
147
+ check('proven: due again after 10 minutes',
148
+ runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 10 * MINUTE), true);
149
+
150
+ console.log('\na proven pane still scans once before its first backstop');
151
+ check('proven but never scanned: due',
152
+ runner.claudeScanDue({ hookProven: true, lastScanAt: 0 }, NOW), true);
153
+
154
  // ---------- hook installer: merge, never replace; idempotent; refuse corrupt ----------
155
  console.log('\ninstaller merges into existing settings and is idempotent');
156
  const settings = path.join(CFG, 'settings.json');