File size: 18,248 Bytes
9fb59f5
 
 
 
 
 
 
 
dce99f0
 
9fb59f5
 
 
 
dce99f0
 
9fb59f5
 
dce99f0
9fb59f5
 
 
dce99f0
 
9fb59f5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b614df
9fb59f5
 
 
 
9b614df
9fb59f5
 
 
 
9b614df
9fb59f5
 
 
 
9b614df
9fb59f5
 
9b614df
9fb59f5
1dec339
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7112a46
 
dce99f0
7112a46
dce99f0
7112a46
 
 
 
dce99f0
 
7112a46
 
 
 
 
 
 
 
dce99f0
 
7112a46
 
1dec339
 
 
 
 
 
 
 
 
7112a46
 
 
dce99f0
7112a46
 
 
 
 
 
 
 
 
 
dce99f0
 
 
 
 
 
 
 
 
 
 
 
f1575b9
 
dce99f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b8a289
 
 
 
 
 
 
 
cb0645f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b8a289
 
 
9b614df
 
 
 
9b8a289
 
 
 
 
 
9b614df
9b8a289
 
9b614df
 
 
 
9b8a289
 
 
 
 
7112a46
 
 
 
 
 
 
 
df112f9
 
 
 
7112a46
 
 
df112f9
7112a46
 
 
 
 
 
9fb59f5
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
// Conversation re-pinning: which transcript a session should follow.
//
// Regression cover for the bug where a mid-session `/clear` left the pin on the
// abandoned conversation, so `--resume` restored the pre-/clear thread after a
// restart. Run with: node test/repin.test.mjs
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'repin-'));
const CFG = path.join(TMP, 'cfg');
const DATA = path.join(TMP, 'data');
const REPIN = path.join(TMP, 'repin');
const CODEX = path.join(TMP, 'codex');
fs.mkdirSync(path.join(CFG, 'projects'), { recursive: true });
fs.mkdirSync(DATA, { recursive: true });
fs.mkdirSync(path.join(CODEX, 'sessions', '2026', '08', '06'), { recursive: true });

process.env.CLAUDE_CONFIG_DIR = CFG;
process.env.DATA_DIR = DATA;
process.env.AM_REPIN_DIR = REPIN;
process.env.CODEX_HOME = CODEX;

const sessions = await import('../src/sessions.js');
const runner = await import('../src/runner.js');
const cfg = await import('../src/config.js');
sessions.init();

const WORKDIR = path.join(cfg.WORKSPACES_DIR, 'proj-a');
fs.mkdirSync(WORKDIR, { recursive: true });

// A transcript opens with metadata lines that carry no cwd (mode,
// file-history-snapshot, …); only the conversation lines have one. Mirror that,
// or the head-reading logic isn't really being exercised.
function transcript(uuid, { cwd = WORKDIR, startMs, mtimeMs, projDir = '-proj-a' }) {
  const dir = path.join(CFG, 'projects', projDir);
  fs.mkdirSync(dir, { recursive: true });
  const p = path.join(dir, `${uuid}.jsonl`);
  fs.writeFileSync(p, [
    JSON.stringify({ type: 'mode', mode: 'default' }),
    JSON.stringify({ type: 'file-history-snapshot' }),
    JSON.stringify({ type: 'user', sessionId: uuid, cwd, timestamp: new Date(startMs).toISOString() }),
  ].join('\n') + '\n');
  const t = (mtimeMs ?? startMs) / 1000;
  fs.utimesSync(p, t, t);
  return p;
}

let pass = 0, fail = 0;
const check = (name, got, want) => {
  const ok = got === want;
  ok ? pass++ : fail++;
  console.log(`  ${ok ? 'PASS' : 'FAIL'}  ${name}${ok ? '' : `\n          got ${got}  want ${want}`}`);
};

const NOW = 1770000000000; // fixed clock: these are all relative comparisons
const SINCE = NOW - 2000;  // the launch window scheduleClaudeCapture opens with
const A = 'aaaaaaaa-0000-0000-0000-000000000001';
const B = 'bbbbbbbb-0000-0000-0000-000000000002';

console.log('\n/clear: the later-born conversation in this folder wins');
transcript(A, { startMs: NOW });
transcript(B, { startMs: NOW + 60_000 });
check('follows the successor', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);

console.log('\na conversation in a different folder is never claimed');
transcript('cccccccc-0000-0000-0000-000000000003',
  { cwd: path.join(cfg.WORKSPACES_DIR, 'proj-b'), startMs: NOW + 120_000, projDir: '-proj-b' });
check('other folder ignored', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);

console.log('\nan older thread that merely received writes is not a successor');
transcript('dddddddd-0000-0000-0000-000000000004', { startMs: NOW - 3600_000, mtimeMs: NOW + 180_000 });
check('pre-window thread rejected despite a fresh mtime',
  (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, B);

console.log('\na conversation another session has pinned is left to it');
const rival = sessions.create({ name: 'rival', cli: 'claude', path: 'proj-a' });
sessions.update(rival.id, { sessionUuid: B });
check('claimed uuid skipped', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, A);

console.log('\nnothing new in the window means no re-pin');
check('no candidate', await runner.claudeCandidate('s1', path.join(cfg.WORKSPACES_DIR, 'empty'), SINCE), null);

// ---------- the folder is a tree ----------
// An agent that enters a git worktree is still the same pane in the same folder,
// and the conversation its /clear starts records the DEEPER cwd. Equality here
// meant the pin could never follow that — the reader kept showing the pre-/clear
// thread while the terminal showed the new one, and the next launch would
// `--resume` the abandoned id.
console.log('\ncwdUnderWorkdir: the pane owns its whole tree, and nothing beside it');
const WT = path.join(WORKDIR, '.claude', 'worktrees', 'wt-1');
check('the folder itself', runner.cwdUnderWorkdir(WORKDIR, WORKDIR), true);
check('a worktree below it', runner.cwdUnderWorkdir(WT, WORKDIR), true);
check('a plain subdirectory', runner.cwdUnderWorkdir(path.join(WORKDIR, 'server'), WORKDIR), true);
check('a sibling sharing a name prefix',
  runner.cwdUnderWorkdir(`${WORKDIR}2`, WORKDIR), false);
check('the parent folder', runner.cwdUnderWorkdir(cfg.WORKSPACES_DIR, WORKDIR), false);
check('somewhere else entirely', runner.cwdUnderWorkdir('/elsewhere', WORKDIR), false);
check('no cwd at all', runner.cwdUnderWorkdir(null, WORKDIR), false);
check('no workdir at all', runner.cwdUnderWorkdir(WORKDIR, ''), false);

console.log('\na /clear inside a worktree is followed');
const F = 'ffffffff-0000-0000-0000-000000000006';
transcript(F, { cwd: WT, startMs: NOW + 240_000, projDir: '-proj-a--claude-worktrees-wt-1' });
check('worktree conversation claimed', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, F);

console.log('\na neighbouring folder whose name starts the same is still not ours');
transcript('99999999-0000-0000-0000-000000000009',
  { cwd: `${WORKDIR}2`, startMs: NOW + 300_000, projDir: '-proj-a2' });
check('prefix neighbour ignored', (await runner.claudeCandidate('s1', WORKDIR, SINCE))?.uuid, F);

// ---------- breadcrumbs: attribution the scan cannot do in shared folders ----------
const E = 'eeeeeeee-0000-0000-0000-000000000005';
const RUN = '11111111-2222-4333-8444-555555555555';
const crumb = (over = {}) => ({
  amId: 's1', runId: RUN, cli: 'claude', claudePid: 4242,
  payload: { session_id: E, cwd: WORKDIR, source: 'clear' },
  ...over,
});
const facts = (over = {}) => ({
  cli: 'claude', runId: RUN, workdir: WORKDIR, pinned: A,
  claimed: new Set([B]), pidTrusted: true, ...over,
});
const verdict = (c, f) => runner.breadcrumbVerdict(c, 's1', f);

console.log('\na trusted breadcrumb re-pins, no folder-sharing question asked');
check('clean crumb accepted', verdict(crumb(), facts()).repin, E);

console.log('\na breadcrumb only speaks for the pane that wrote it');
check('amId mismatch rejected', verdict(crumb({ amId: 's2' }), facts()).repin, null);
check('stale launch rejected', verdict(crumb({ runId: 'old-run' }), facts()).why, 'runId mismatch');
check('wrong harness rejected', verdict(crumb({ cli: 'codex' }), facts()).why, 'cli mismatch');
check('cwd mismatch rejected',
  verdict(crumb({ payload: { session_id: E, cwd: '/elsewhere', source: 'clear' } }), facts()).repin, null);
check('a crumb from a parent folder rejected',
  verdict(crumb({ payload: { session_id: E, cwd: cfg.WORKSPACES_DIR, source: 'clear' } }), facts()).why,
  'cwd outside the session folder');
check('a crumb from a prefix neighbour rejected',
  verdict(crumb({ payload: { session_id: E, cwd: `${WORKDIR}2`, source: 'clear' } }), facts()).repin, null);

console.log('\na /clear reported from a worktree below the folder is this pane speaking');
check('worktree crumb accepted',
  verdict(crumb({ payload: { session_id: E, cwd: WT, source: 'clear' } }), facts()).repin, E);

console.log('\na nested claude -p cannot claim the pane (pid not under the pane root)');
check('untrusted pid rejected', verdict(crumb(), facts({ pidTrusted: false })).repin, null);
check('untrusted pid says why', verdict(crumb(), facts({ pidTrusted: false })).why, 'pid not top-level pane agent');

console.log('\nno-ops and garbage stay no-ops');
check('already-pinned crumb is a no-op',
  verdict(crumb({ payload: { session_id: A, cwd: WORKDIR, source: 'resume' } }), facts()).why, 'already pinned');
check('claimed uuid left to its session',
  verdict(crumb({ payload: { session_id: B, cwd: WORKDIR, source: 'clear' } }), facts()).why, 'claimed by another session');
check('malformed session_id rejected',
  verdict(crumb({ payload: { session_id: 'not-a-uuid', cwd: WORKDIR } }), facts()).repin, null);
check('null crumb rejected', verdict(null, facts()).repin, null);

console.log('\nCodex and OpenCode use the same pane/run attribution contract');
const CODEX_ID = '12345678-1234-4234-8234-123456789abc';
const rollout = path.join(CODEX, 'sessions', '2026', '08', '06', `rollout-2026-08-06T00-00-00-${CODEX_ID}.jsonl`);
fs.writeFileSync(rollout, '{}\n');
const codexCrumb = {
  amId: 's1', runId: RUN, cli: 'codex',
  payload: { session_id: CODEX_ID, transcript_path: rollout, cwd: WORKDIR, source: 'clear' },
};
check('Codex exact id accepted', runner.breadcrumbVerdict(codexCrumb, 's1', {
  cli: 'codex', runId: RUN, workdir: WORKDIR, pinned: null, claimed: new Set(), pidTrusted: true,
}).repin, CODEX_ID);
check('Codex rollout path retained', runner.codexRolloutForBreadcrumb(codexCrumb), rollout);
// Awaited: the id lookup walks the rollout tree, which is async now.
check('Codex null transcript resolves by exact id', await runner.codexRolloutForId(CODEX_ID), rollout);
const CODEX_ALIAS = path.join(TMP, 'codex-alias');
fs.mkdirSync(CODEX_ALIAS);
fs.symlinkSync(path.join(CODEX, 'sessions'), path.join(CODEX_ALIAS, 'sessions'));
process.env.CODEX_HOME = CODEX_ALIAS;
check('Codex canonical path accepted through a symlinked sessions root',
  runner.codexRolloutForBreadcrumb(codexCrumb), rollout);
process.env.CODEX_HOME = CODEX;
check('Codex path outside CODEX_HOME rejected', runner.codexRolloutForBreadcrumb({
  ...codexCrumb, payload: { ...codexCrumb.payload, transcript_path: `/tmp/rollout-x-${CODEX_ID}.jsonl` },
}), null);
check('OpenCode exact id accepted', runner.breadcrumbVerdict({
  amId: 's1', runId: RUN, cli: 'opencode',
  payload: { session_id: 'ses_1234567890abcdef', cwd: WORKDIR },
}, 's1', {
  cli: 'opencode', runId: RUN, workdir: WORKDIR, pinned: null, claimed: new Set(), pidTrusted: true,
}).repin, 'ses_1234567890abcdef');

console.log('\nonly the top-level agent process may emit a breadcrumb');
const proc = new Map([
  [99, '99 (bash) S 1 0 0'],
  [100, '100 (node) S 1 0 0'],
  [101, '101 (claude) S 100 0 0'],
  [102, '102 (nested claude) S 101 0 0'],
  [110, '110 (codex-x64 (native)) S 100 0 0'],
  [120, '120 (tool shell) S 110 0 0'],
  [200, '200 (node) S 120 0 0'],
  [210, '210 (codex-x64) S 200 0 0'],
]);
const readStat = (pid) => {
  if (!proc.has(pid)) throw new Error('gone');
  return proc.get(pid);
};
check('pane root accepted', runner.pidIsPaneRootOrDirectChild(100, 100, readStat), true);
check('direct Claude child accepted', runner.pidIsPaneRootOrDirectChild(101, 100, readStat), true);
check('nested Claude rejected', runner.pidIsPaneRootOrDirectChild(102, 100, readStat), false);
check('top native Codex accepted', runner.codexProcessPidTrusted(110, 100, readStat), true);
proc.set(100, '100 (node) S 99 0 0');
check('Codex behind retained launch shell accepted', runner.codexProcessPidTrusted(110, 99, readStat), true);
check('nested native Codex rejected', runner.codexProcessPidTrusted(210, 100, readStat), false);
check('gone Codex pid rejected', runner.codexProcessPidTrusted(999, 100, readStat), false);

console.log('\nhook scripts preserve the pane and launch identity');
const scripts = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts');
const repoRoot = path.dirname(scripts);
const requirements = fs.readFileSync(path.join(repoRoot, 'codex-requirements.toml'), 'utf8');
check('Codex hook is managed (no user trust prompt)', requirements.includes('managed_dir = "/etc/codex/hooks"'), true);
check('managed policy runs the root-owned hook', requirements.includes('command = "/etc/codex/hooks/am-codex-repin-hook.sh"'), true);
const hookPayload = JSON.stringify({ session_id: E, transcript_path: '/tmp/t.jsonl', cwd: WORKDIR, source: 'clear' });
let hook = spawnSync('sh', [path.join(scripts, 'am-repin-hook.sh')], {
  input: hookPayload,
  env: {
    ...process.env, AM_ID: 's1', AM_RUN_ID: RUN, AM_CLI: 'claude',
    AM_PANE_PID: String(process.pid), CLAUDE_CODE_ENTRYPOINT: 'cli', CLAUDE_PID: String(process.pid),
  },
});
check('Claude hook exits cleanly', hook.status, 0);
let written = JSON.parse(fs.readFileSync(path.join(REPIN, 's1.claude.json'), 'utf8'));
check('Claude hook writes run id', written.runId, RUN);
check('Claude hook writes exact session id', written.payload.session_id, E);

const codexShim = path.join(TMP, 'codex-test');
fs.symlinkSync('/bin/sh', codexShim);
const paneShim = path.join(TMP, 'pane-shell');
fs.symlinkSync('/bin/sh', paneShim);
const codexLauncher = path.join(TMP, 'codex-launcher.cjs');
fs.writeFileSync(codexLauncher, [
  "const fs = require('node:fs');",
  "const { spawnSync } = require('node:child_process');",
  "const child = spawnSync(process.argv[2], ['-c', `sh '${process.argv[3]}'`], {",
  "  input: fs.readFileSync(0), env: process.env, stdio: ['pipe', 'inherit', 'inherit'],",
  "});",
  "process.exit(child.status ?? 1);",
].join('\n'));
hook = spawnSync(paneShim, ['-c',
  `export AM_PANE_PID=$$; node '${codexLauncher}' '${codexShim}' '${path.join(scripts, 'am-codex-repin-hook.sh')}'`], {
  input: JSON.stringify(codexCrumb.payload),
  env: { ...process.env, AM_ID: 's1', AM_RUN_ID: RUN, AM_CLI: 'codex' },
});
check('Codex hook exits cleanly', hook.status, 0);
written = JSON.parse(fs.readFileSync(path.join(REPIN, 's1.codex.json'), 'utf8'));
check('Codex hook writes run id', written.runId, RUN);
check('Codex hook writes exact session id', written.payload.session_id, CODEX_ID);
check('Codex hook records its live agent process', Number.isInteger(written.codexPid), true);

// ---------- the pane root: the fact every breadcrumb is trusted against ----------
// paneRootPid used to shell out to `tmux list-panes`. The libghostty migration
// removed tmux but left the call, referencing identifiers that no longer exist —
// so it threw into its own bare catch, returned null, and every breadcrumb was
// rejected as 'pid not in pane'. A null pane root disables the hook path wholesale,
// so this asserts against a REAL running session rather than a mock.
console.log('\nthe pane root is the session PTY the server holds');
check('unknown session has no pane root', runner.paneRootPid('nope'), null);
// Spawning a real pane needs libghostty's native addon, which not every checkout
// has built. Skip rather than throw out of ensureRunning: an unhandled throw here
// aborts the process and takes the cadence and installer checks below with it,
// which reads as a broken test run rather than a missing optional dependency.
if (!runner.ghosttyReady()) {
  console.log('  SKIP  live pane checks (libghostty-vt unavailable)');
} else {
  const live = sessions.create({ name: 'live', cli: 'shell', path: 'proj-a' });
  try {
    runner.ensureRunning(live, 80, 24);
    const liveRoot = runner.paneRootPid(live.id);
    check('live session has a pane root', Number.isInteger(liveRoot) && liveRoot > 1, true);
    check('the pane root is a live process', fs.existsSync(`/proc/${liveRoot}`), true);
  } finally {
    runner.stop(live.id);
  }
}

// ---------- scan cadence: the breadcrumb is the mechanism, the scan a backstop ----------
// With the scan awaited the cadence is no longer about event-loop block time — it
// is about not walking the bucket 3x a minute per pane for an answer the
// breadcrumb already gave. So the backstop is a minute, and a minute is also the
// longest a pin can stay stale if a crumb is ever lost.
const MINUTE = 60_000;
console.log('\nwithout a proven hook the scan runs every tick (unchanged)');
check('no proof: due immediately', runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW), true);
check('no proof: still due 20s later',
  runner.claudeScanDue({ hookProven: false, lastScanAt: NOW }, NOW + 20_000), true);

console.log('\na proven hook steps the scan down to the backstop cadence');
check('proven: not due on the next beat',
  runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + 20_000), false);
check('proven: not due at 59s',
  runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + MINUTE - 1000), false);
check('proven: due again at a minute',
  runner.claudeScanDue({ hookProven: true, lastScanAt: NOW }, NOW + MINUTE), true);

console.log('\na proven pane still scans once before its first backstop');
check('proven but never scanned: due',
  runner.claudeScanDue({ hookProven: true, lastScanAt: 0 }, NOW), true);

// ---------- hook installer: merge, never replace; idempotent; refuse corrupt ----------
console.log('\ninstaller merges into existing settings and is idempotent');
const settings = path.join(CFG, 'settings.json');
fs.writeFileSync(settings, JSON.stringify({ model: 'opus', permissions: { allow: ['Bash'] } }));
check('installs into existing file', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
let s = JSON.parse(fs.readFileSync(settings, 'utf8'));
check('other keys kept', s.model, 'opus');
check('hook entry present', s.hooks.SessionStart.length, 1);
check('attention notification hook present', s.hooks.Notification.length, 1);
check('attention hook uses post-dialog events', s.hooks.Notification[0].matcher,
  'permission_prompt|elicitation_dialog|elicitation_url_dialog|agent_needs_input|agent_completed');
check('attention marker gets a native clear path', s.hooks.PostToolBatch.length, 1);
check('second run is a no-op', runner.installClaudeRepinHook('/app/scripts/am-repin-hook.sh'), true);
s = JSON.parse(fs.readFileSync(settings, 'utf8'));
check('no duplicate entry', s.hooks.SessionStart.length, 1);
check('no duplicate attention entry', s.hooks.Notification.length, 1);

console.log('\ninstaller refuses to clobber a corrupt settings file');
fs.writeFileSync(settings, '{ not json');
check('corrupt file left alone', runner.installClaudeRepinHook('/x.sh'), false);
check('corrupt content untouched', fs.readFileSync(settings, 'utf8'), '{ not json');

console.log(`\n${pass} passed, ${fail} failed`);
fs.rmSync(TMP, { recursive: true, force: true });
process.exit(fail ? 1 : 0);