Spaces:
Running
Repin: fix the pane root, then stop scanning the bucket every 20s
Browse filesTwo 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>
- server/src/runner.js +57 -11
- server/test/repin.test.mjs +42 -0
|
@@ -976,16 +976,23 @@ function takeClaudeBreadcrumb(sessionId) {
|
|
| 976 |
try { return JSON.parse(raw); } catch { return null; }
|
| 977 |
}
|
| 978 |
|
| 979 |
-
// The pane's root process
|
| 980 |
-
// claude; in the `claude --session-id … || exec claude`
|
| 981 |
-
// child of it. Either way the hook's $CLAUDE_PID must descend
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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';
|
|
@@ -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');
|