Thomas Wolf Claude Opus 5 commited on
Commit
9fb59f5
·
unverified ·
1 Parent(s): 90fc965

Sessions: follow the conversation when a harness starts a new one (#17)

Browse files

A session's conversation pin was captured once, just after launch, and then
never revisited. `/clear` ends the Claude conversation and starts a new one
with an id of its own -- the pinned transcript stops growing and Claude writes
a new file -- so from that moment the pin described a conversation the user had
left. The Overview digest, the trace panel and sharing all kept reading the
pre-/clear thread, and because the old transcript still existed on disk the
next launch ran `--resume <old uuid>` and restored the conversation as it was
BEFORE the /clear, discarding everything since. That is what made a Space
restart bring back the previous session instead of the current one.

Codex and opencode had the same hole for the same reason: both captured their
id once and stopped.

So keep the watcher running for as long as the pane is alive and follow the
session onto whatever conversation the harness is actually writing. One
mechanism now covers both failure modes -- the launch-time `|| exec claude`
fallback where the pin was never honoured, and a reset at any later point.

Guards kept from the launch-time version, since a periodic scan needs them
more, not less: only claim a conversation born in this launch window, only one
started in this session's folder, and never one another session has pinned.
Added a refusal to guess at all when a second live session of the same harness
shares the folder -- there is no way to tell whose /clear produced a new
conversation. Writes to sessions.json only happen when the id actually
changes, and transcript heads are memoized, because this scan now runs for the
life of every session against a FUSE mount rather than once per launch.

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

server/package.json CHANGED
@@ -9,7 +9,8 @@
9
  "main": "src/index.js",
10
  "scripts": {
11
  "start": "node src/index.js",
12
- "dev": "node --watch src/index.js"
 
13
  },
14
  "dependencies": {
15
  "express": "^4.19.2",
 
9
  "main": "src/index.js",
10
  "scripts": {
11
  "start": "node src/index.js",
12
+ "dev": "node --watch src/index.js",
13
+ "test": "node test/repin.test.mjs"
14
  },
15
  "dependencies": {
16
  "express": "^4.19.2",
server/src/runner.js CHANGED
@@ -145,7 +145,12 @@ export function deriveState(session, info) {
145
  // its first line. Capture that id shortly after launch and pin it on the
146
  // session, so restarts resume THIS agent's conversation — `resume --last`
147
  // would grab whichever Codex agent in the same folder ran last.
148
- const codexCapturing = new Set(); // session ids with a capture in flight
 
 
 
 
 
149
 
150
  function codexSessionsRoot() {
151
  const home = process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
@@ -227,6 +232,7 @@ function firstLine(p) {
227
 
228
  function tryCaptureCodexId(sessionId, workdir, sinceMs) {
229
  const claimed = new Set(list().filter((s) => s.id !== sessionId && s.codexSessionId).map((s) => s.codexSessionId));
 
230
  for (const c of codexRolloutsSince(sinceMs)) {
231
  const m = c.p.match(/rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
232
  if (!m || claimed.has(m[1])) continue;
@@ -243,6 +249,10 @@ function tryCaptureCodexId(sessionId, workdir, sinceMs) {
243
  // aren't this agent's conversation, so pinning one would break resume and
244
  // the Overview digest.
245
  if (mp.thread_source === 'subagent' || (mp.source && mp.source.subagent)) continue;
 
 
 
 
246
  update(sessionId, { codexSessionId: m[1], codexRollout: c.p });
247
  return true;
248
  }
@@ -262,24 +272,38 @@ function pinIsStale(session) {
262
  } catch { return false; }
263
  }
264
 
 
 
 
 
 
265
  function scheduleCodexCapture(session, workdir) {
266
  if (session.codexSessionId && pinIsStale(session)) {
267
  session = update(session.id, { codexSessionId: undefined, codexRollout: undefined }) || session;
268
  }
269
- if (session.codexSessionId || codexCapturing.has(session.id)) return;
270
- codexCapturing.add(session.id);
271
  const since = Date.now() - 2000;
272
- const delays = [5000, 15000, 45000]; // rollout appears ~instantly; retries cover slow starts
273
- const attempt = (i) => {
274
- if (tryCaptureCodexId(session.id, workdir, since) || i + 1 >= delays.length) {
275
- codexCapturing.delete(session.id);
276
- return;
 
 
 
 
 
 
277
  }
278
- const t = setTimeout(() => attempt(i + 1), delays[i + 1] - delays[i]);
279
  if (t.unref) t.unref();
 
280
  };
281
- const t0 = setTimeout(() => attempt(0), delays[0]);
 
282
  if (t0.unref) t0.unref();
 
283
  }
284
 
285
  // opencode has no per-conversation handle we can pass on launch, so we can't
@@ -296,10 +320,10 @@ function scheduleCodexCapture(session, workdir) {
296
  // forever: the Overview shows no digest, `--resume` can't find the transcript so
297
  // the session silently starts fresh every launch, and sharing can't locate it.
298
  //
299
- // So verify the pin after launch the same way codex/opencode capture theirs, and
300
- // re-pin to the transcript Claude actually wrote. Observed live on a test Space:
301
  // session pinned 4efced14…, transcript on disk cb22b656….
302
- const claudeCapturing = new Set();
303
 
304
  function claudeProjectDirs() {
305
  const home = process.env.HOME || '';
@@ -338,58 +362,145 @@ const transcriptExists = (uuid) =>
338
  });
339
  });
340
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  function scheduleClaudeCapture(session, workdir) {
342
- if (claudeCapturing.has(session.id)) return;
343
- claudeCapturing.add(session.id);
 
 
344
  const since = Date.now() - 2000;
345
- const delays = [5000, 15000, 45000, 90000];
346
- const attempt = (i) => {
347
- // The pin is fine as soon as a matching transcript exists — the common case.
348
- if (transcriptExists(session.sessionUuid)) { claudeCapturing.delete(session.id); return; }
349
-
350
- const claimed = new Set(list().filter((s) => s.id !== session.id && s.sessionUuid).map((s) => s.sessionUuid));
351
- for (const c of claudeTranscriptsSince(since)) {
352
- const uuid = path.basename(c.p).replace(/\.jsonl$/, '');
353
- if (claimed.has(uuid)) continue;
354
- // The cwd is NOT on the first line: a transcript opens with metadata lines
355
- // (mode, permission-mode, file-history-snapshot, ai-title, worktree-state)
356
- // that have no cwd, and only the conversation lines carry one — line 4 or 5
357
- // in every real transcript measured. Reading line 1 made this check always
358
- // fail, so the re-pin could never actually claim anything.
359
- const head = transcriptHead(c.p);
360
- // Only claim a conversation started in THIS session's folder.
361
- if (!head || head.cwd !== workdir) continue;
362
- const created = Date.parse(head.timestamp || '') || 0;
363
- if (created && created < since - 15_000) continue; // someone else's older thread
364
- console.warn(`[claude] re-pinning ${session.id}: ${session.sessionUuid} -> ${uuid} (--session-id was not honoured)`);
365
- update(session.id, { sessionUuid: uuid });
366
- claudeCapturing.delete(session.id);
367
- return;
368
  }
369
- if (i + 1 >= delays.length) { claudeCapturing.delete(session.id); return; }
370
- const t = setTimeout(() => attempt(i + 1), delays[i + 1] - delays[i]);
371
  if (t.unref) t.unref();
 
372
  };
373
- const t0 = setTimeout(() => attempt(0), delays[0]);
 
374
  if (t0.unref) t0.unref();
 
375
  }
376
 
377
- const opencodeCapturing = new Set();
 
 
 
 
378
  function scheduleOpencodeCapture(session, workdir) {
379
- if (session.opencodeSessionId || opencodeCapturing.has(session.id)) return;
380
- opencodeCapturing.add(session.id);
381
  const since = Date.now() - 2000;
382
- const delays = [3000, 8000, 20000, 45000, 90000];
383
- const attempt = (i) => {
384
- const claimed = new Set(list().filter((s) => s.id !== session.id && s.opencodeSessionId).map((s) => s.opencodeSessionId));
385
- const hit = captureOpencodeSession(workdir, since, claimed);
386
- if (hit) { update(session.id, { opencodeSessionId: hit.id }); opencodeCapturing.delete(session.id); return; }
387
- if (i + 1 >= delays.length) { opencodeCapturing.delete(session.id); return; }
388
- const t = setTimeout(() => attempt(i + 1), delays[i + 1] - delays[i]);
 
 
 
 
 
 
 
 
 
 
 
 
389
  if (t.unref) t.unref();
 
390
  };
391
- const t0 = setTimeout(() => attempt(0), delays[0]);
 
392
  if (t0.unref) t0.unref();
 
393
  }
394
 
395
  // Single-quote a string for embedding in an `sh -lc` command line.
 
145
  // its first line. Capture that id shortly after launch and pin it on the
146
  // session, so restarts resume THIS agent's conversation — `resume --last`
147
  // would grab whichever Codex agent in the same folder ran last.
148
+ const codexCapturing = new Map(); // id -> pending re-pin timer
149
+
150
+ // Every harness's conversation pin is re-checked on this cadence for as long as
151
+ // the pane is alive, so a mid-session reset (/clear and friends) can't leave the
152
+ // pin describing a conversation the user has moved on from.
153
+ const REPIN_MS = 20_000;
154
 
155
  function codexSessionsRoot() {
156
  const home = process.env.CODEX_HOME || path.join(process.env.HOME || os.homedir(), '.codex');
 
232
 
233
  function tryCaptureCodexId(sessionId, workdir, sinceMs) {
234
  const claimed = new Set(list().filter((s) => s.id !== sessionId && s.codexSessionId).map((s) => s.codexSessionId));
235
+ const pinned = (list().find((s) => s.id === sessionId) || {}).codexSessionId;
236
  for (const c of codexRolloutsSince(sinceMs)) {
237
  const m = c.p.match(/rollout-.*-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/);
238
  if (!m || claimed.has(m[1])) continue;
 
249
  // aren't this agent's conversation, so pinning one would break resume and
250
  // the Overview digest.
251
  if (mp.thread_source === 'subagent' || (mp.source && mp.source.subagent)) continue;
252
+ // The watcher re-runs for the life of the pane, so the usual outcome is
253
+ // "still the same conversation" — don't rewrite sessions.json for that.
254
+ if (m[1] === pinned) return true;
255
+ if (pinned) console.warn(`[codex] re-pinning ${sessionId}: ${pinned} -> ${m[1]} (conversation was replaced)`);
256
  update(sessionId, { codexSessionId: m[1], codexRollout: c.p });
257
  return true;
258
  }
 
272
  } catch { return false; }
273
  }
274
 
275
+ // Same staleness problem as Claude's pin, same remedy: codex starts a fresh
276
+ // conversation — and a fresh rollout file — when the thread is reset, so a pin
277
+ // captured once at launch stops describing the live conversation. Keep watching
278
+ // for as long as the pane is alive and follow the newest rollout this folder
279
+ // produces. tryCaptureCodexId only writes when the id actually changes.
280
  function scheduleCodexCapture(session, workdir) {
281
  if (session.codexSessionId && pinIsStale(session)) {
282
  session = update(session.id, { codexSessionId: undefined, codexRollout: undefined }) || session;
283
  }
284
+ const prev = codexCapturing.get(session.id);
285
+ if (prev) clearTimeout(prev);
286
  const since = Date.now() - 2000;
287
+ let warnedShared = false;
288
+
289
+ const tick = () => {
290
+ if (!isRunning(session.id)) { codexCapturing.delete(session.id); return; }
291
+ if (folderIsShared(session.id, workdir, 'codex')) {
292
+ if (!warnedShared) {
293
+ warnedShared = true;
294
+ console.warn(`[codex] ${session.id}: folder shared with another live session — not following thread resets here`);
295
+ }
296
+ } else {
297
+ tryCaptureCodexId(session.id, workdir, since);
298
  }
299
+ const t = setTimeout(tick, REPIN_MS);
300
  if (t.unref) t.unref();
301
+ codexCapturing.set(session.id, t);
302
  };
303
+
304
+ const t0 = setTimeout(tick, 5000); // rollout appears ~instantly
305
  if (t0.unref) t0.unref();
306
+ codexCapturing.set(session.id, t0);
307
  }
308
 
309
  // opencode has no per-conversation handle we can pass on launch, so we can't
 
320
  // forever: the Overview shows no digest, `--resume` can't find the transcript so
321
  // the session silently starts fresh every launch, and sharing can't locate it.
322
  //
323
+ // So verify the pin after launch and keep verifying it, see the watcher below —
324
+ // re-pinning to the transcript Claude actually wrote. Observed live on a test Space:
325
  // session pinned 4efced14…, transcript on disk cb22b656….
326
+ const claudeCapturing = new Map(); // id -> pending re-pin timer
327
 
328
  function claudeProjectDirs() {
329
  const home = process.env.HOME || '';
 
362
  });
363
  });
364
 
365
+ // A transcript's opening cwd/timestamp never changes once written, and the
366
+ // filename IS the conversation id, so a path is never reused for a different
367
+ // conversation. That makes the head safe to remember — which matters because the
368
+ // watcher rescans every REPIN_MS for the life of every session, and on the Space
369
+ // these are synchronous reads against a FUSE mount. Without this, every tick
370
+ // re-read every transcript on disk and would show up as event-loop lag.
371
+ const headMemo = new Map(); // transcript path -> head
372
+
373
+ function transcriptHeadCached(p) {
374
+ const hit = headMemo.get(p);
375
+ if (hit) return hit;
376
+ const head = transcriptHead(p);
377
+ // Only remember a definite answer: null can just mean the file has no cwd line
378
+ // yet (still being written), and caching that would poison it for the process.
379
+ if (head) {
380
+ if (headMemo.size > 500) headMemo.clear();
381
+ headMemo.set(p, head);
382
+ }
383
+ return head;
384
+ }
385
+
386
+ // The newest conversation written in `workdir` that no other session has pinned,
387
+ // as { uuid, start }. `start` is the conversation's OWN first timestamp, which is
388
+ // what distinguishes a /clear-spawned successor from the thread it replaced —
389
+ // both keep receiving mtime updates, only the successor is newly born.
390
+ // Exported for server/test/repin.test.mjs.
391
+ export function claudeCandidate(sessionId, workdir, sinceMs) {
392
+ const claimed = new Set(list().filter((s) => s.id !== sessionId && s.sessionUuid).map((s) => s.sessionUuid));
393
+ let best = null;
394
+ for (const c of claudeTranscriptsSince(sinceMs)) {
395
+ const uuid = path.basename(c.p).replace(/\.jsonl$/, '');
396
+ if (claimed.has(uuid)) continue;
397
+ // The cwd is NOT on the first line: a transcript opens with metadata lines
398
+ // (mode, permission-mode, file-history-snapshot, ai-title, worktree-state)
399
+ // that have no cwd, and only the conversation lines carry one — line 4 or 5
400
+ // in every real transcript measured. Reading line 1 made this check always
401
+ // fail, so the re-pin could never actually claim anything.
402
+ const head = transcriptHeadCached(c.p);
403
+ // Only claim a conversation started in THIS session's folder.
404
+ if (!head || head.cwd !== workdir) continue;
405
+ const start = Date.parse(head.timestamp || '') || 0;
406
+ // Born in this launch window, or it's an older thread that merely received
407
+ // writes — someone else's, or our own pre-relaunch one.
408
+ if (start && start < sinceMs - 15_000) continue;
409
+ if (!best || start > best.start) best = { uuid, start };
410
+ }
411
+ return best;
412
+ }
413
+
414
+ // Another LIVE session of the same harness on the same folder makes a new
415
+ // conversation there unattributable: we cannot tell whose /clear produced it.
416
+ // Refuse to guess, the way share.js does when a folder has rivals.
417
+ function folderIsShared(sessionId, workdir, cli) {
418
+ return list().some((s) => s.id !== sessionId && s.cli === cli
419
+ && path.join(WORKSPACES_DIR, s.path ?? s.id) === workdir && isRunning(s.id));
420
+ }
421
+
422
+ // Re-pinning is NOT a one-shot check, because the pin can go stale mid-session.
423
+ // `/clear` ends the conversation and starts a new one with an id of its own,
424
+ // exactly like the onboarding fallback above: the transcript we pinned stops
425
+ // growing and Claude writes a NEW file. Nothing told the manager, so the pin
426
+ // aged out silently — the Overview digest, the trace panel and sharing all kept
427
+ // reading the pre-/clear thread, and the next launch ran `--resume <old uuid>`,
428
+ // restoring the conversation as it was BEFORE the /clear and discarding
429
+ // everything since. That is why a Space restart brought back the old session.
430
+ //
431
+ // So the watcher keeps looking for as long as the pane is alive and follows the
432
+ // session forward onto whatever conversation Claude is actually writing. One
433
+ // mechanism now covers both failure modes: the launch-time fallback (pin never
434
+ // honoured) and a /clear at any later point.
435
  function scheduleClaudeCapture(session, workdir) {
436
+ // A relaunch restarts the watch with a fresh window, so `since` can't drift
437
+ // older and start admitting pre-relaunch threads as candidates.
438
+ const prev = claudeCapturing.get(session.id);
439
+ if (prev) clearTimeout(prev);
440
  const since = Date.now() - 2000;
441
+ let warnedShared = false;
442
+
443
+ const tick = () => {
444
+ if (!isRunning(session.id)) { claudeCapturing.delete(session.id); return; }
445
+ if (folderIsShared(session.id, workdir, 'claude')) {
446
+ if (!warnedShared) {
447
+ warnedShared = true;
448
+ console.warn(`[claude] ${session.id}: folder shared with another live session — not following /clear here`);
449
+ }
450
+ } else {
451
+ const pinned = (list().find((s) => s.id === session.id) || session).sessionUuid;
452
+ const hit = claudeCandidate(session.id, workdir, since);
453
+ if (hit && hit.uuid !== pinned) {
454
+ const why = transcriptExists(pinned) ? 'conversation was replaced (/clear)' : '--session-id was not honoured';
455
+ console.warn(`[claude] re-pinning ${session.id}: ${pinned} -> ${hit.uuid} (${why})`);
456
+ update(session.id, { sessionUuid: hit.uuid });
457
+ }
 
 
 
 
 
 
458
  }
459
+ const t = setTimeout(tick, REPIN_MS);
 
460
  if (t.unref) t.unref();
461
+ claudeCapturing.set(session.id, t);
462
  };
463
+
464
+ const t0 = setTimeout(tick, 5000);
465
  if (t0.unref) t0.unref();
466
+ claudeCapturing.set(session.id, t0);
467
  }
468
 
469
+ const opencodeCapturing = new Map(); // id -> pending re-pin timer
470
+
471
+ // As with Claude and codex, the pin has to keep up with the live conversation:
472
+ // starting a new opencode conversation writes a new `session` row, and a pin
473
+ // captured once at launch would keep pointing at the abandoned one.
474
  function scheduleOpencodeCapture(session, workdir) {
475
+ const prev = opencodeCapturing.get(session.id);
476
+ if (prev) clearTimeout(prev);
477
  const since = Date.now() - 2000;
478
+ let warnedShared = false;
479
+
480
+ const tick = () => {
481
+ if (!isRunning(session.id)) { opencodeCapturing.delete(session.id); return; }
482
+ if (folderIsShared(session.id, workdir, 'opencode')) {
483
+ if (!warnedShared) {
484
+ warnedShared = true;
485
+ console.warn(`[opencode] ${session.id}: folder shared with another live session — not following new conversations here`);
486
+ }
487
+ } else {
488
+ const claimed = new Set(list().filter((s) => s.id !== session.id && s.opencodeSessionId).map((s) => s.opencodeSessionId));
489
+ const pinned = (list().find((s) => s.id === session.id) || session).opencodeSessionId;
490
+ const hit = captureOpencodeSession(workdir, since, claimed);
491
+ if (hit && hit.id !== pinned) {
492
+ if (pinned) console.warn(`[opencode] re-pinning ${session.id}: ${pinned} -> ${hit.id} (conversation was replaced)`);
493
+ update(session.id, { opencodeSessionId: hit.id });
494
+ }
495
+ }
496
+ const t = setTimeout(tick, REPIN_MS);
497
  if (t.unref) t.unref();
498
+ opencodeCapturing.set(session.id, t);
499
  };
500
+
501
+ const t0 = setTimeout(tick, 3000);
502
  if (t0.unref) t0.unref();
503
+ opencodeCapturing.set(session.id, t0);
504
  }
505
 
506
  // Single-quote a string for embedding in an `sh -lc` command line.
server/test/repin.test.mjs ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Conversation re-pinning: which transcript a session should follow.
2
+ //
3
+ // Regression cover for the bug where a mid-session `/clear` left the pin on the
4
+ // abandoned conversation, so `--resume` restored the pre-/clear thread after a
5
+ // restart. Run with: node test/repin.test.mjs
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import os from 'node:os';
9
+
10
+ const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'repin-'));
11
+ const CFG = path.join(TMP, 'cfg');
12
+ const DATA = path.join(TMP, 'data');
13
+ fs.mkdirSync(path.join(CFG, 'projects'), { recursive: true });
14
+ fs.mkdirSync(DATA, { recursive: true });
15
+
16
+ process.env.CLAUDE_CONFIG_DIR = CFG;
17
+ process.env.DATA_DIR = DATA;
18
+
19
+ const sessions = await import('../src/sessions.js');
20
+ const runner = await import('../src/runner.js');
21
+ const cfg = await import('../src/config.js');
22
+ sessions.init();
23
+
24
+ const WORKDIR = path.join(cfg.WORKSPACES_DIR, 'proj-a');
25
+ fs.mkdirSync(WORKDIR, { recursive: true });
26
+
27
+ // A transcript opens with metadata lines that carry no cwd (mode,
28
+ // file-history-snapshot, …); only the conversation lines have one. Mirror that,
29
+ // or the head-reading logic isn't really being exercised.
30
+ function transcript(uuid, { cwd = WORKDIR, startMs, mtimeMs, projDir = '-proj-a' }) {
31
+ const dir = path.join(CFG, 'projects', projDir);
32
+ fs.mkdirSync(dir, { recursive: true });
33
+ const p = path.join(dir, `${uuid}.jsonl`);
34
+ fs.writeFileSync(p, [
35
+ JSON.stringify({ type: 'mode', mode: 'default' }),
36
+ JSON.stringify({ type: 'file-history-snapshot' }),
37
+ JSON.stringify({ type: 'user', sessionId: uuid, cwd, timestamp: new Date(startMs).toISOString() }),
38
+ ].join('\n') + '\n');
39
+ const t = (mtimeMs ?? startMs) / 1000;
40
+ fs.utimesSync(p, t, t);
41
+ return p;
42
+ }
43
+
44
+ let pass = 0, fail = 0;
45
+ const check = (name, got, want) => {
46
+ const ok = got === want;
47
+ ok ? pass++ : fail++;
48
+ console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${ok ? '' : `\n got ${got} want ${want}`}`);
49
+ };
50
+
51
+ const NOW = 1770000000000; // fixed clock: these are all relative comparisons
52
+ const SINCE = NOW - 2000; // the launch window scheduleClaudeCapture opens with
53
+ const A = 'aaaaaaaa-0000-0000-0000-000000000001';
54
+ const B = 'bbbbbbbb-0000-0000-0000-000000000002';
55
+
56
+ console.log('\n/clear: the later-born conversation in this folder wins');
57
+ transcript(A, { startMs: NOW });
58
+ transcript(B, { startMs: NOW + 60_000 });
59
+ check('follows the successor', runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid, B);
60
+
61
+ console.log('\na conversation in a different folder is never claimed');
62
+ transcript('cccccccc-0000-0000-0000-000000000003',
63
+ { cwd: path.join(cfg.WORKSPACES_DIR, 'proj-b'), startMs: NOW + 120_000, projDir: '-proj-b' });
64
+ check('other folder ignored', runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid, B);
65
+
66
+ console.log('\nan older thread that merely received writes is not a successor');
67
+ transcript('dddddddd-0000-0000-0000-000000000004', { startMs: NOW - 3600_000, mtimeMs: NOW + 180_000 });
68
+ check('pre-window thread rejected despite a fresh mtime',
69
+ runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid, B);
70
+
71
+ console.log('\na conversation another session has pinned is left to it');
72
+ const rival = sessions.create({ name: 'rival', cli: 'claude', path: 'proj-a' });
73
+ sessions.update(rival.id, { sessionUuid: B });
74
+ check('claimed uuid skipped', runner.claudeCandidate('s1', WORKDIR, SINCE)?.uuid, A);
75
+
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);