@spexcode/spec-cli 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/bin/spex.mjs +56 -19
  2. package/dist/cli.js +102 -59
  3. package/dist/client.d.ts +1 -3
  4. package/dist/client.js +49 -30
  5. package/dist/codex-runtime-generations.d.ts +11 -0
  6. package/dist/codex-runtime-generations.js +46 -9
  7. package/dist/delivery-lock.d.ts +2 -0
  8. package/dist/delivery-lock.js +58 -0
  9. package/dist/doctor.js +53 -11
  10. package/dist/execution-trace.d.ts +1 -0
  11. package/dist/execution-trace.js +2 -2
  12. package/dist/gateway-hub.js +2 -1
  13. package/dist/gateway.js +6 -3
  14. package/dist/graphCache.js +32 -2
  15. package/dist/graphSnapshot.js +57 -2
  16. package/dist/graphStream.d.ts +2 -0
  17. package/dist/graphStream.js +83 -3
  18. package/dist/guide.js +20 -7
  19. package/dist/harness-select.js +16 -3
  20. package/dist/harness.d.ts +15 -3
  21. package/dist/harness.js +331 -50
  22. package/dist/help.js +11 -8
  23. package/dist/hook-prompts.js +8 -0
  24. package/dist/host-resources.js +29 -8
  25. package/dist/host.d.ts +7 -0
  26. package/dist/host.js +93 -0
  27. package/dist/index.js +324 -22
  28. package/dist/init.js +1 -1
  29. package/dist/lint.js +70 -35
  30. package/dist/listen.d.ts +3 -2
  31. package/dist/listen.js +14 -2
  32. package/dist/machine-peer.js +1 -1
  33. package/dist/materialize.d.ts +2 -2
  34. package/dist/materialize.js +176 -35
  35. package/dist/pty-bridge.js +14 -14
  36. package/dist/reviews.js +12 -7
  37. package/dist/runtime-ownership.d.ts +11 -0
  38. package/dist/runtime-ownership.js +79 -1
  39. package/dist/session-application.d.ts +23 -0
  40. package/dist/session-application.js +189 -0
  41. package/dist/session-declarations.js +13 -1
  42. package/dist/session-files.d.ts +6 -0
  43. package/dist/session-files.js +13 -1
  44. package/dist/session-follow.js +39 -22
  45. package/dist/session-record-lock.d.ts +3 -0
  46. package/dist/session-record-lock.js +94 -0
  47. package/dist/session-runtime-adapter.d.ts +44 -0
  48. package/dist/session-runtime-adapter.js +37 -0
  49. package/dist/session-timeline.d.ts +25 -2
  50. package/dist/session-timeline.js +68 -11
  51. package/dist/session-web.js +4 -4
  52. package/dist/sessions.d.ts +108 -15
  53. package/dist/sessions.js +1465 -744
  54. package/dist/source-list.d.ts +13 -0
  55. package/dist/source-list.js +99 -0
  56. package/dist/source-read.d.ts +16 -0
  57. package/dist/source-read.js +84 -0
  58. package/dist/spec-attachments.d.ts +7 -0
  59. package/dist/spec-attachments.js +89 -0
  60. package/dist/spec-body-edit.d.ts +23 -0
  61. package/dist/spec-body-edit.js +138 -0
  62. package/dist/supervise.js +15 -6
  63. package/dist/transcript-reader.d.ts +36 -0
  64. package/dist/transcript-reader.js +251 -0
  65. package/hooks/dispatch.sh +19 -31
  66. package/hooks/harness.sh +6 -6
  67. package/package.json +6 -6
  68. package/templates/hooks/post-checkout +4 -2
  69. package/templates/hooks/post-merge +2 -1
  70. package/templates/hooks/pre-commit +5 -3
  71. package/templates/hooks/reference-transaction +5 -3
  72. package/templates/spec/project/.plugins/commands/spec.md +2 -7
  73. package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  74. package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  75. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  76. package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  77. package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  78. package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  79. package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  80. package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  81. package/templates/spec/project/.plugins/core/spec.md +2 -0
  82. package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  83. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  84. package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  85. package/templates/spec/project/.plugins/skills/spec.md +2 -6
  86. package/templates/spec/project/.plugins/spec.md +7 -0
  87. package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  88. package/hooks/compat/mark-active-sed-v0.fixture +0 -46
package/dist/harness.js CHANGED
@@ -20,6 +20,7 @@ import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, pr
20
20
  import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
21
21
  import { writeFileIfChanged } from './file-write.js';
22
22
  import { codexRolloutPath, noExecutionTrace, readCodexExecutionTrace, readLocalStoreExecutionTrace, readProjectJsonlExecutionTrace, readSessionJsonlExecutionTrace } from './execution-trace.js';
23
+ import { readClaudeTranscript, readCodexTranscript, unsupportedTranscriptReader } from './transcript-reader.js';
23
24
  import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
24
25
  // One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
25
26
  // once per call, then its result is joined to every record that names that adapter/thread. Product readers must
@@ -302,21 +303,38 @@ async function claudeDeliveryTransport(rec) {
302
303
  return rendezvousDeliveryTransport(rec);
303
304
  }
304
305
  const unprovenDeliveryTransport = async () => ({ kind: 'unproven' });
305
- function replyViaSocket(sock, text, mid, auth) {
306
+ // Claude's rendezvous daemon owns one connection at a time. A liveness probe can therefore destroy a
307
+ // delivery connection after its write has reached the kernel but before the daemon parses the line. Send the
308
+ // reply and an in-order repaint probe in one chunk: repaint-done proves the reply line was parsed first; a
309
+ // close/reset before that proves the whole chunk was discarded and is safe to retry. An open connection that
310
+ // outlives the wall is treated as busy, not lost, preserving the no-false-failure behavior for active turns.
311
+ function replyViaSocket(sock, text, mid, auth, wallMs = 10_000) {
306
312
  return new Promise((resolve) => {
307
313
  let settled = false;
308
314
  let c;
315
+ let wall;
309
316
  const done = (r) => {
310
317
  if (settled)
311
318
  return;
312
319
  settled = true;
313
- if (!r.ok)
320
+ clearTimeout(wall);
321
+ if (r.ok) {
322
+ // A confirmed repaint has already crossed the parser. On a busy timeout, finish the stream cleanly so
323
+ // the daemon can consume the buffered pair before its FIN; never destroy an unparsed write.
324
+ try {
325
+ c?.end();
326
+ }
327
+ catch { /* */ }
328
+ }
329
+ else {
314
330
  try {
315
331
  c?.destroy();
316
332
  }
317
333
  catch { /* */ }
334
+ }
318
335
  resolve(r);
319
336
  };
337
+ wall = setTimeout(() => done({ ok: true }), wallMs);
320
338
  try {
321
339
  c = createConnection({ path: sock });
322
340
  }
@@ -326,29 +344,49 @@ function replyViaSocket(sock, text, mid, auth) {
326
344
  }
327
345
  c.on('error', (e) => {
328
346
  const code = e?.code || String(e);
329
- done({ ok: false, error: `rendezvous socket error: ${code}` });
347
+ done({ ok: false, kicked: code === 'ECONNRESET' || code === 'EPIPE', error: `rendezvous socket error: ${code} — prompt NOT delivered` });
348
+ });
349
+ c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection closed before the daemon parsed the prompt (kicked by a concurrent connect)' }));
350
+ c.on('connect', () => c.write(`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n${JSON.stringify({ type: 'repaint' })}\n`));
351
+ let buf = '';
352
+ c.on('data', (chunk) => {
353
+ buf += chunk.toString('utf8');
354
+ let nl;
355
+ while ((nl = buf.indexOf('\n')) >= 0) {
356
+ const line = buf.slice(0, nl);
357
+ buf = buf.slice(nl + 1);
358
+ let type = '';
359
+ try {
360
+ type = JSON.parse(line)?.type ?? '';
361
+ }
362
+ catch {
363
+ continue;
364
+ }
365
+ if (type === 'repaint-done')
366
+ return done({ ok: true });
367
+ if (type === 'reply-rejected' || type === 'auth-rejected')
368
+ return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` });
369
+ if (type === 'shutting-down')
370
+ return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' });
371
+ }
330
372
  });
331
- c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }));
332
- c.on('connect', () => c.write(`${auth ? JSON.stringify({ role: 'controller', auth }) + '\n' : ''}${JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) })}\n`, (error) => {
333
- if (error)
334
- return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` });
335
- c.end();
336
- done({ ok: true });
337
- }));
338
373
  });
339
374
  }
340
375
  const POKE_ATTEMPTS = 2;
341
- async function pokeRendezvous(sock, text, mid, auth) {
376
+ async function pokeRendezvous(sock, text, mid, auth, wallMs) {
342
377
  let last = { ok: false, error: 'not attempted' };
343
378
  for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
344
- last = await replyViaSocket(sock, text, mid, auth);
379
+ last = await replyViaSocket(sock, text, mid, auth, wallMs);
345
380
  if (last.ok)
346
381
  return last;
382
+ if (!last.kicked)
383
+ break;
384
+ await new Promise((resolve) => setTimeout(resolve, 60 + Math.random() * 140));
347
385
  }
348
386
  return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` };
349
387
  }
350
- export async function deliverViaRendezvous(id, text, mid) {
351
- return pokeRendezvous(rvSock(id), text, mid);
388
+ export async function deliverViaRendezvous(id, text, mid, wallMs) {
389
+ return pokeRendezvous(rvSock(id), text, mid, undefined, wallMs);
352
390
  }
353
391
  export async function deliverViaClaudeRendezvous(id, text, mid, runtimeDir) {
354
392
  const fork = claudeForkTransport(id, runtimeDir);
@@ -375,8 +413,8 @@ export async function deliverViaSocketOrWake(id, text, mid, coldWake, unprovenEr
375
413
  // 0.142.3 (`codex app-server generate-ts` → ClientRequest.ts / v2/*Params.ts): the visible TUI is launched with
376
414
  // `codex --remote unix://<sock>`, so its thread is ALREADY loaded in this server — we must NOT `thread/resume`
377
415
  // it (that re-loads a thread the live TUI already owns). Instead `thread/loaded/list` PROVES the captured thread
378
- // is the one the pane is showing, then `thread/read{includeTurns}` reveals whether a turn is in progress (and
379
- // its id). The 4th, injecting message is CHOSEN from that read — see codexInjectMessage.
416
+ // is the one the pane is showing. The failure observer owns the active native turn id from app-server
417
+ // notifications; delivery does not read the thread or replay its history — see codexInjectMessage.
380
418
  const codexTextInput = (text) => [{ type: 'text', text, text_elements: [] }];
381
419
  export function codexHandshakeMessages(threadId) {
382
420
  return [
@@ -390,7 +428,6 @@ export function codexHandshakeMessages(threadId) {
390
428
  },
391
429
  { method: 'initialized', params: {} },
392
430
  { id: 2, method: 'thread/loaded/list', params: {} },
393
- { id: 3, method: 'thread/read', params: { threadId, includeTurns: true } },
394
431
  ];
395
432
  }
396
433
  // the message that injects `text`. STEER (turn/steer) when an active turn id is known — codex processes it
@@ -406,7 +443,9 @@ export function codexInjectMessage(threadId, text, cwd, activeTurnId, id = 4, cl
406
443
  return { id, method: 'turn/steer', params: { threadId, input: codexTextInput(text), expectedTurnId: activeTurnId, ...marker } };
407
444
  return { id, method: 'turn/start', params: { threadId, input: codexTextInput(text), ...(cwd ? { cwd } : {}), ...marker } };
408
445
  }
409
- // the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. With
446
+ // the in-progress turn id from a `thread/read{includeTurns}` result, or null when the thread is idle. This is
447
+ // retained for ownership/replay probes; delivery uses codexObservedActiveTurnId because it must not request the
448
+ // full history. With
410
449
  // includeTurns the Thread carries its turns, each with a TurnStatus ("completed"|"interrupted"|"failed"|
411
450
  // "inProgress"); the live turn is the `inProgress` one and its id is exactly what turn/steer's precondition needs.
412
451
  export function activeTurnIdFromThread(readResult) {
@@ -415,6 +454,20 @@ export function activeTurnIdFromThread(readResult) {
415
454
  const active = turns.find((t) => t?.status === 'inProgress');
416
455
  return active?.id ?? null;
417
456
  }
457
+ // The app-server's lightweight thread/read intentionally omits the turn list. Keep the native active id
458
+ // observed by the failure subscription so a send can steer without replaying the whole conversation.
459
+ const codexActiveTurns = new Map();
460
+ export function codexObservedActiveTurnId(threadId) {
461
+ return codexActiveTurns.get(threadId) || null;
462
+ }
463
+ function rememberCodexActiveTurn(threadId, turnId) {
464
+ if (typeof turnId === 'string' && turnId)
465
+ codexActiveTurns.set(threadId, turnId);
466
+ }
467
+ function forgetCodexActiveTurn(threadId, turnId) {
468
+ if (turnId === undefined || codexActiveTurns.get(threadId) === turnId)
469
+ codexActiveTurns.delete(threadId);
470
+ }
418
471
  // The app-server and the visible `--remote … resume` TUI share ONE socket, so they MUST be the SAME codex
419
472
  // install — a version split across that socket breaks the thread/start→resume handoff (an app-server on one
420
473
  // version creates a thread a differently-versioned resume can't find; an old-enough app-server can't serve
@@ -556,7 +609,11 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
556
609
  // launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
557
610
  // the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
558
611
  // creates a context stamps that context's record id, and nothing downstream re-derives it.
559
- ...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --remote unix://"$sock" resume "$tid"`] : []),
612
+ // A remote Codex TUI cannot inherit the launch shell's cwd: when `tui.resume_cwd = "current"` is
613
+ // configured, Codex requires an explicit workspace root for every `--remote` invocation. Keep the
614
+ // directory tied to the generated script's actual pane cwd so linked worktrees with spaces remain one
615
+ // argument and the resumed thread loads the same project context as thread/start above.
616
+ ...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --cd "$PWD" --remote unix://"$sock" resume "$tid"`] : []),
560
617
  ].join('\n');
561
618
  return `bash -lc ${shQuote(script)} spexcode-codex`;
562
619
  }
@@ -590,7 +647,7 @@ function encodeWsFrame(opcode, payload) {
590
647
  return Buffer.concat([header, mask, masked]);
591
648
  }
592
649
  const wsText = (s) => encodeWsFrame(0x1, Buffer.from(s, 'utf8'));
593
- function drainWsFrames(s, conn, onText) {
650
+ function drainWsFrames(s, conn, onText, acceptPayload = () => true) {
594
651
  for (;;) {
595
652
  if (s.buf.length < 2)
596
653
  return false;
@@ -635,7 +692,7 @@ function drainWsFrames(s, conn, onText) {
635
692
  s.fragBuf = payload;
636
693
  }
637
694
  if (fin) {
638
- if (s.fragOp === 0x1)
695
+ if (s.fragOp === 0x1 && acceptPayload(s.fragBuf))
639
696
  onText(s.fragBuf.toString('utf8'));
640
697
  s.fragBuf = Buffer.alloc(0);
641
698
  s.fragOp = 0;
@@ -644,6 +701,9 @@ function drainWsFrames(s, conn, onText) {
644
701
  }
645
702
  const WS_UPGRADE = (key) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`;
646
703
  const wsInitialize = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } };
704
+ // Native Codex can take 15-17s to answer thread/resume under a loaded app-server. A shorter
705
+ // deadline creates a retry storm in the session reconciler, not a useful failure signal.
706
+ export const CODEX_TURN_OBSERVER_SUBSCRIBE_MS = 30_000;
647
707
  // Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
648
708
  // with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
649
709
  // this observer also survives backend replacement; a thread already in systemError is reconciled from its
@@ -665,6 +725,9 @@ export function codexTurnFailureObserver(rec, onFailure) {
665
725
  const frames = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
666
726
  let upgraded = false, settled = false;
667
727
  let reconciliationTimer = null;
728
+ let readySettled = false;
729
+ let resolveReady;
730
+ const ready = new Promise((resolve) => { resolveReady = resolve; });
668
731
  let resolveClosed;
669
732
  const closed = new Promise((resolve) => { resolveClosed = resolve; });
670
733
  const cancelReconciliation = () => {
@@ -677,6 +740,10 @@ export function codexTurnFailureObserver(rec, onFailure) {
677
740
  if (settled)
678
741
  return;
679
742
  settled = true;
743
+ if (!readySettled) {
744
+ readySettled = true;
745
+ resolveReady(false);
746
+ }
680
747
  clearTimeout(timer);
681
748
  cancelReconciliation();
682
749
  try {
@@ -685,7 +752,7 @@ export function codexTurnFailureObserver(rec, onFailure) {
685
752
  catch { }
686
753
  resolveClosed(reason);
687
754
  };
688
- const timer = setTimeout(() => finish('Codex turn observer did not subscribe within 5000ms'), 5000);
755
+ const timer = setTimeout(() => finish(`Codex turn observer did not subscribe within ${CODEX_TURN_OBSERVER_SUBSCRIBE_MS}ms`), CODEX_TURN_OBSERVER_SUBSCRIBE_MS);
689
756
  timer.unref?.();
690
757
  const send = (message) => conn.write(wsText(JSON.stringify(message)));
691
758
  const report = (turn, fallbackMessage) => {
@@ -702,6 +769,11 @@ export function codexTurnFailureObserver(rec, onFailure) {
702
769
  conn.on('close', () => finish('Codex turn observer connection closed'));
703
770
  conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
704
771
  const handle = (json) => {
772
+ // A resumed Codex thread can stream large goal/progress notifications while the turn runs. They are
773
+ // irrelevant to this failure observer; avoid JSON.parse on those payloads so one active turn cannot make
774
+ // the backend spend its memory and CPU re-materializing a transcript it does not use.
775
+ if (json.includes('"method"') && !json.includes('"method":"turn/started"') && !json.includes('"method":"turn/completed"'))
776
+ return;
705
777
  let message;
706
778
  try {
707
779
  message = JSON.parse(json);
@@ -721,10 +793,18 @@ export function codexTurnFailureObserver(rec, onFailure) {
721
793
  }
722
794
  if (message.id === 2 && message.result) {
723
795
  clearTimeout(timer);
796
+ if (!readySettled) {
797
+ readySettled = true;
798
+ resolveReady(true);
799
+ }
724
800
  const result = message.result;
801
+ const initialTurns = result.initialTurnsPage?.data;
802
+ const initialActive = Array.isArray(initialTurns)
803
+ ? initialTurns.find((turn) => turn?.status === 'inProgress')
804
+ : null;
805
+ rememberCodexActiveTurn(threadId, initialActive?.id);
725
806
  if (result.thread?.status?.type === 'systemError') {
726
- const turns = result.initialTurnsPage?.data;
727
- const latest = Array.isArray(turns) ? turns[0] : null;
807
+ const latest = Array.isArray(initialTurns) ? initialTurns[0] : null;
728
808
  // Give a concurrently-starting turn's native notification precedence over this historical snapshot.
729
809
  reconciliationTimer = setTimeout(() => {
730
810
  reconciliationTimer = null;
@@ -736,12 +816,15 @@ export function codexTurnFailureObserver(rec, onFailure) {
736
816
  }
737
817
  if (message.method === 'turn/started') {
738
818
  const params = message.params;
739
- if (params?.threadId === threadId)
819
+ if (params?.threadId === threadId) {
820
+ rememberCodexActiveTurn(threadId, params.turn?.id);
740
821
  cancelReconciliation();
822
+ }
741
823
  }
742
824
  if (message.method === 'turn/completed') {
743
825
  const params = message.params;
744
826
  if (params?.threadId === threadId) {
827
+ forgetCodexActiveTurn(threadId, params.turn?.id);
745
828
  cancelReconciliation();
746
829
  report(params.turn);
747
830
  }
@@ -760,10 +843,13 @@ export function codexTurnFailureObserver(rec, onFailure) {
760
843
  frames.buf = frames.buf.slice(split + 4);
761
844
  send(wsInitialize);
762
845
  }
763
- if (drainWsFrames(frames, conn, handle))
846
+ if (drainWsFrames(frames, conn, handle, (payload) => {
847
+ const method = Buffer.from('"method"');
848
+ return !payload.includes(method) || payload.includes(Buffer.from('"turn/started"')) || payload.includes(Buffer.from('"turn/completed"'));
849
+ }))
764
850
  finish('Codex app-server closed the turn observer');
765
851
  });
766
- return { close: () => finish(null), closed };
852
+ return { close: () => finish(null), closed, ready };
767
853
  }
768
854
  function codexThreadMutation(sock, method, threadId, fence, turnId, budgetMs = CODEX_MUTATION_BASE_MS) {
769
855
  const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
@@ -1579,7 +1665,7 @@ export function codexThreadId(sock) {
1579
1665
  // Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
1580
1666
  // they are never treated as references by themselves. A loaded thread is a control-plane reference and its
1581
1667
  // fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
1582
- export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)) {
1668
+ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir), referenceIds) {
1583
1669
  const sock = endpoint.socketPath;
1584
1670
  return (async () => {
1585
1671
  // File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
@@ -1676,11 +1762,19 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCo
1676
1762
  // Continue with the complete paginated set, not just the first manager page.
1677
1763
  if (!loadedIds.size)
1678
1764
  return done({ healthy: true, references: [] });
1679
- loadedIds.forEach((threadId) => {
1765
+ const wanted = referenceIds === undefined ? [...loadedIds] : [...loadedIds].filter((threadId) => referenceIds.includes(threadId));
1766
+ loadedIds.forEach((threadId) => references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' }));
1767
+ // A draining generation deliberately has no native turn reads. Complete the census after recording
1768
+ // loaded ownership; leaving the request map empty would otherwise wait for the global timeout forever.
1769
+ if (!wanted.length)
1770
+ return done({ healthy: true, references: [...references.values()] });
1771
+ wanted.forEach((threadId) => {
1680
1772
  const id = 100 + requests.size;
1681
- references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' });
1682
1773
  requests.set(id, threadId);
1683
- send({ id, method: 'thread/read', params: { threadId, includeTurns: true } });
1774
+ // Ownership sampling only needs the native status, never the persisted turn history. The old
1775
+ // includeTurns:true request made a periodic resource report replay every loaded conversation and
1776
+ // was the direct source of 5s probe timeouts on the draining generation.
1777
+ send({ id, method: 'thread/read', params: { threadId, includeTurns: false } });
1684
1778
  });
1685
1779
  return;
1686
1780
  }
@@ -1689,9 +1783,10 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCo
1689
1783
  requests.delete(m.id);
1690
1784
  const thread = m.result.thread;
1691
1785
  const turnId = activeTurnIdFromThread(m.result);
1786
+ const nativeStatus = typeof thread?.status === 'string' ? thread.status : thread?.status?.type;
1692
1787
  references.set(threadId, {
1693
1788
  referenceId: threadId,
1694
- turnPresence: !Array.isArray(thread?.turns) ? 'unknown' : turnId ? 'active' : 'idle',
1789
+ turnPresence: turnId || nativeStatus === 'active' ? 'active' : nativeStatus === 'idle' ? 'idle' : 'unknown',
1695
1790
  ...(turnId ? { turnId } : {}),
1696
1791
  });
1697
1792
  if (!requests.size)
@@ -1812,7 +1907,7 @@ const codexTurnConfirmMs = () => {
1812
1907
  function sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId) {
1813
1908
  return new Promise((resolve) => {
1814
1909
  const conn = createConnection(sock);
1815
- const hs = codexHandshakeMessages(threadId); // [initialize(1), initialized, thread/loaded/list(2), thread/read(3)]
1910
+ const hs = codexHandshakeMessages(threadId); // [initialize(1), initialized, thread/loaded/list(2)]
1816
1911
  let buf = Buffer.alloc(0), upgraded = false, settled = false;
1817
1912
  let fragOp = 0, fragBuf = Buffer.alloc(0);
1818
1913
  let steering = false; // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
@@ -1857,14 +1952,11 @@ function sendCodexAppServerTurn(sock, threadId, text, cwd, clientUserMessageId)
1857
1952
  send(hs[1]);
1858
1953
  return send(hs[2]);
1859
1954
  } // initialize ack → initialized → ask which threads are loaded
1860
- if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
1955
+ if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then inject
1861
1956
  const loaded = m.result?.data;
1862
1957
  if (Array.isArray(loaded) && !loaded.includes(threadId))
1863
1958
  return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — immediate poke not accepted` });
1864
- return send(hs[3]); // thread is live → read it to decide steer-vs-start
1865
- }
1866
- if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
1867
- const turnId = activeTurnIdFromThread(m.result);
1959
+ const turnId = codexObservedActiveTurnId(threadId);
1868
1960
  steering = !!turnId;
1869
1961
  return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)); // id 4: turn/steer the live turn, or turn/start
1870
1962
  }
@@ -2106,6 +2198,157 @@ export function removeManagedBlock(file, comment = ['<!-- ', ' -->'], deleteIfEm
2106
2198
  }
2107
2199
  writeFileSync(file, out);
2108
2200
  }
2201
+ // @@@ managed-json-hooks - the JSON counterpart of writeManagedBlock/removeManagedBlock, for a shim file the
2202
+ // host agent SHARES with the user. `.claude/settings.json` (and `.codex/hooks.json`, `.zcode/settings.json`)
2203
+ // is the user's project config — permissions, env, statusLine, their own hooks — that merely HAPPENS to be
2204
+ // where the harness also discovers ours. A whole-file write there is silent data loss, and a whole-file
2205
+ // delete on uninstall makes it permanent for an untracked (gitignored) file. JSON has no comment syntax, so
2206
+ // the sentinel that scopes ownership is the hook COMMAND itself: every entry we write invokes `dispatch.sh`,
2207
+ // and ONLY such entries are ever removed. Everything else round-trips — other keys, other events, foreign
2208
+ // hook groups, and the user's half of a group that mixes both.
2209
+ const isOurHookEntry = (entry) => !!entry && typeof entry === 'object' && typeof entry.command === 'string'
2210
+ && entry.command.includes('dispatch.sh');
2211
+ // drop OUR entries from one event's group list, keeping every foreign group byte-for-byte and keeping the
2212
+ // user's half of a mixed group. A group whose entries were all ours disappears with it.
2213
+ function stripOurHookGroups(list) {
2214
+ if (!Array.isArray(list))
2215
+ return Array.isArray(list) ? list : [];
2216
+ const kept = [];
2217
+ for (const group of list) {
2218
+ const inner = group?.hooks;
2219
+ if (!group || typeof group !== 'object' || !Array.isArray(inner)) {
2220
+ kept.push(group);
2221
+ continue;
2222
+ }
2223
+ const rest = inner.filter((e) => !isOurHookEntry(e));
2224
+ if (rest.length === inner.length)
2225
+ kept.push(group); // nothing of ours in here
2226
+ else if (rest.length)
2227
+ kept.push({ ...group, hooks: rest }); // mixed group — keep their half
2228
+ }
2229
+ return kept;
2230
+ }
2231
+ // read a shared shim file as JSON. Absent → {} (we are about to create it). UNPARSEABLE → throw: the file is
2232
+ // the user's, and overwriting prose we cannot read is exactly the data loss this primitive exists to prevent
2233
+ // (the harness itself cannot read it either, so the repair is the same one they already need).
2234
+ function readSharedShim(file) {
2235
+ if (!existsSync(file))
2236
+ return {};
2237
+ const raw = readFileSync(file, 'utf8');
2238
+ if (!raw.trim())
2239
+ return {};
2240
+ // (re-serialization NORMALIZES the host's formatting — 2-space, one member per line. Their CONTENT all
2241
+ // round-trips; their layout does not, because no JSON writer can reproduce hand-compacted objects. The one
2242
+ // byte-level convention we DO honor is the trailing newline, below.)
2243
+ try {
2244
+ const parsed = JSON.parse(raw);
2245
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
2246
+ throw new Error('not a JSON object');
2247
+ return parsed;
2248
+ }
2249
+ catch (e) {
2250
+ throw new Error(`it is not readable JSON (${e.message}). SpexCode co-owns only its own hook entries in this file and will not overwrite content it cannot parse — fix the JSON (your harness cannot read it either), then re-run \`spex materialize\`.`);
2251
+ }
2252
+ }
2253
+ // does this file end with a newline today? A host file keeps its own convention; a file we are about to
2254
+ // create gets the POSIX one. (Absent → true, so a fresh shim is newline-terminated.)
2255
+ function trailingNewline(file) {
2256
+ if (!existsSync(file))
2257
+ return true;
2258
+ try {
2259
+ return /\n$/.test(readFileSync(file, 'utf8'));
2260
+ }
2261
+ catch {
2262
+ return true;
2263
+ }
2264
+ }
2265
+ const hostHooksOf = (host) => {
2266
+ const h = host.hooks;
2267
+ return h && typeof h === 'object' && !Array.isArray(h) ? h : {};
2268
+ };
2269
+ // MERGE our per-event hook groups into a shared shim file: our previous entries are stripped first (so a
2270
+ // changed dispatch path or event set self-heals instead of accumulating), then re-appended AFTER the user's
2271
+ // groups for that event. Every other key keeps its value and its position.
2272
+ export function writeManagedJsonHooks(file, hooks) {
2273
+ const eol = trailingNewline(file) ? '\n' : '';
2274
+ const host = readSharedShim(file);
2275
+ const merged = {};
2276
+ for (const [event, list] of Object.entries(hostHooksOf(host))) {
2277
+ const kept = stripOurHookGroups(list);
2278
+ if (kept.length)
2279
+ merged[event] = kept;
2280
+ }
2281
+ for (const [event, groups] of Object.entries(hooks))
2282
+ merged[event] = [...(merged[event] ?? []), ...groups];
2283
+ return writeFileIfChanged(file, JSON.stringify({ ...host, hooks: merged }, null, 2) + eol);
2284
+ }
2285
+ // the INVERSE: strip our entries, leave every other byte of meaning intact. The file is REMOVED only when
2286
+ // nothing of the user's remains ({} after our entries go) — the same deleteIfEmpty rule removeManagedBlock
2287
+ // applies to a wholly-ours CLAUDE.md, and safe here only because the write half no longer clobbers.
2288
+ export function removeManagedJsonHooks(file) {
2289
+ if (!existsSync(file))
2290
+ return;
2291
+ const eol = trailingNewline(file) ? '\n' : '';
2292
+ let host;
2293
+ try {
2294
+ host = readSharedShim(file);
2295
+ }
2296
+ catch {
2297
+ return;
2298
+ } // unparseable → not provably ours, leave it alone
2299
+ const rest = { ...host };
2300
+ const merged = {};
2301
+ for (const [event, list] of Object.entries(hostHooksOf(host))) {
2302
+ const kept = stripOurHookGroups(list);
2303
+ if (kept.length)
2304
+ merged[event] = kept;
2305
+ }
2306
+ if (Object.keys(merged).length)
2307
+ rest.hooks = merged;
2308
+ else
2309
+ delete rest.hooks;
2310
+ if (!Object.keys(rest).length) {
2311
+ rmSync(file, { force: true });
2312
+ return;
2313
+ }
2314
+ writeFileIfChanged(file, JSON.stringify(rest, null, 2) + eol);
2315
+ }
2316
+ // does anything of the USER's survive in this shared shim file — the JSON analogue of the contract files'
2317
+ // host-content test ([[residence]])? Everything except our identity-stamped hook entries counts. This is what
2318
+ // decides the file's residence: wholly ours → hidden by the tree's ignore block exactly like any other machine
2319
+ // fact; carrying their content → left visible, because hiding a file the user owns is data-loss shaped.
2320
+ export function sharedShimHasHostContent(file) {
2321
+ if (!existsSync(file))
2322
+ return false;
2323
+ let host;
2324
+ try {
2325
+ host = readSharedShim(file);
2326
+ }
2327
+ catch {
2328
+ return true;
2329
+ } // unreadable → assume theirs
2330
+ const rest = { ...host };
2331
+ delete rest.hooks;
2332
+ if (Object.keys(rest).length)
2333
+ return true;
2334
+ return Object.values(hostHooksOf(host)).some((list) => stripOurHookGroups(list).length > 0);
2335
+ }
2336
+ // the identity stamp on every generated skill/agent file. It is what lets BOTH halves of the pass tell our
2337
+ // artifact from a same-named file the user wrote: the erase phase refuses to delete an unstamped file, and
2338
+ // the write phase refuses to overwrite one ([[harness-delivery]]).
2339
+ export const GENERATED_MARK = '<!-- spexcode:generated -->';
2340
+ // is this path ours to replace or remove? An absent file is (nothing to lose); a present one only when it
2341
+ // carries the stamp. Unreadable → not provably ours.
2342
+ export function isGeneratedArtifact(file) {
2343
+ if (!existsSync(file))
2344
+ return true;
2345
+ try {
2346
+ return readFileSync(file, 'utf8').includes(GENERATED_MARK);
2347
+ }
2348
+ catch {
2349
+ return false;
2350
+ }
2351
+ }
2109
2352
  // the shim for one harness: every event → `SPEX='…' bash <dispatch> <harnessId> <Event>`. The harness id is
2110
2353
  // baked in so dispatch.sh can export SPEXCODE_HARNESS (the detector for the shell side). SPEX is inherited by
2111
2354
  // the cli-needing handlers.
@@ -2114,7 +2357,9 @@ function buildShim(id, events, dispatch, spex) {
2114
2357
  const hooks = {};
2115
2358
  for (const e of events)
2116
2359
  hooks[e] = [{ hooks: [{ type: 'command', command: cmd(e) }] }];
2117
- return { content: JSON.stringify({ hooks }, null, 2), cmd };
2360
+ // `content` stays the standalone rendering (what a shim file that is wholly ours would hold); `hooks` is
2361
+ // what the merge writer folds into the user's shared config file. Every buildShim harness is 'shared-json'.
2362
+ return { content: JSON.stringify({ hooks }, null, 2), hooks, cmd };
2118
2363
  }
2119
2364
  // ---------------------------------------------------------------------------------------------------------
2120
2365
  // Codex trust — the codex-rs trusted_hash, reverse-engineered + pinned. Lives in the Codex adapter (it is a
@@ -2233,21 +2478,35 @@ function cleanHarness(h, proj, arts, preserveProject = false) {
2233
2478
  for (const f of h.contractFiles(proj))
2234
2479
  removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f));
2235
2480
  const shim = h.shimFile(proj);
2236
- if ((h.shimScope === 'tree' || !preserveProject) && existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh'))
2237
- rmSync(shim, { force: true });
2481
+ // a SHARED config file is un-written entry by entry (and disappears only if nothing of the user's is left);
2482
+ // a file wholly ours goes whole, gated on its own dispatch.sh stamp.
2483
+ if (h.shimScope === 'tree' || !preserveProject) {
2484
+ if (h.shimOwnership === 'shared-json')
2485
+ removeManagedJsonHooks(shim);
2486
+ else if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh'))
2487
+ rmSync(shim, { force: true });
2488
+ }
2238
2489
  const anchor = h.worktreeHookAnchor(proj); // the linked-worktree anchor copy, same identity gate as the shim
2239
2490
  if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh'))
2240
2491
  rmSync(anchor, { force: true });
2241
2492
  if (!preserveProject)
2242
2493
  h.removeTrust(proj);
2494
+ // the name sweep is identity-gated exactly like the stamp sweep: a live spec node named `distill` says
2495
+ // WHICH path to look at, never that the file sitting there is ours. A user's same-named skill (the write
2496
+ // half now refuses to overwrite it) must survive our uninstall.
2243
2497
  const sd = h.skillDir(proj);
2498
+ const stamped = (f) => existsSync(f) && isGeneratedArtifact(f);
2244
2499
  if (sd)
2245
- for (const n of arts.skills)
2246
- rmSync(join(sd, n), { recursive: true, force: true });
2500
+ for (const n of arts.skills) {
2501
+ if (stamped(join(sd, n, 'SKILL.md')))
2502
+ rmSync(join(sd, n), { recursive: true, force: true });
2503
+ }
2247
2504
  const ad = h.agentDir(proj);
2248
2505
  if (ad)
2249
- for (const n of arts.agents)
2250
- rmSync(join(ad, `${n}.md`), { force: true });
2506
+ for (const n of arts.agents) {
2507
+ if (stamped(join(ad, `${n}.md`)))
2508
+ rmSync(join(ad, `${n}.md`), { force: true });
2509
+ }
2251
2510
  }
2252
2511
  // ---------------------------------------------------------------------------------------------------------
2253
2512
  // codex per-session liveness signal — a codex process live in the pane's DESCENDANT tree, NOT the pane's
@@ -2415,6 +2674,7 @@ export const claudeHarness = {
2415
2674
  ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
2416
2675
  paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
2417
2676
  executionTrace: readProjectJsonlExecutionTrace,
2677
+ readTranscript: readClaudeTranscript,
2418
2678
  launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
2419
2679
  baseCmd: claudeBaseCmd,
2420
2680
  oneShotTurn: (prompt, cmd) => ({ command: `${claudeBaseCmd(cmd)} -p`, stdin: prompt }), // --print reads the prompt from stdin
@@ -2423,6 +2683,7 @@ export const claudeHarness = {
2423
2683
  launchEnv: rendezvousLaunchEnv,
2424
2684
  shimFile: (proj) => join(proj, '.claude', 'settings.json'),
2425
2685
  shimScope: 'tree',
2686
+ shimOwnership: 'shared-json',
2426
2687
  worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
2427
2688
  contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
2428
2689
  skillDir: (proj) => join(proj, '.claude', 'skills'),
@@ -2503,7 +2764,13 @@ function codexRuntimeDescriptor(endpoint, runtimeDir) {
2503
2764
  return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error };
2504
2765
  },
2505
2766
  mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts, endpoint),
2506
- probe: () => codexSharedRuntimeProbe(runtimeDir, endpoint),
2767
+ probe: (referenceIds) => {
2768
+ // A draining generation keeps serving its bound sessions but is no longer a control-plane candidate.
2769
+ // Listing loaded ids preserves ownership evidence; reading native turn state on that old server only
2770
+ // adds load and can time out while the current generation is healthy.
2771
+ const generation = readCodexGenerationLedger(runtimeDir).generations[endpoint.id];
2772
+ return codexSharedRuntimeProbe(runtimeDir, endpoint, generation?.state === 'draining' ? [] : referenceIds);
2773
+ },
2507
2774
  };
2508
2775
  }
2509
2776
  function codexRuntimeDescriptors(runtimeDir) {
@@ -2528,6 +2795,7 @@ export const codexHarness = {
2528
2795
  ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
2529
2796
  paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
2530
2797
  executionTrace: readCodexExecutionTrace,
2798
+ readTranscript: readCodexTranscript,
2531
2799
  launchCmd: (id, runtimeDir, cmd) => codexLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()), // the full app-server+TUI script BUILT AROUND the resolved base command; ONE app-server per PROJECT
2532
2800
  baseCmd: codexBaseCmd,
2533
2801
  oneShotTurn: (prompt, cmd) => ({ command: `${codexBaseCmd(cmd)} exec -`, stdin: prompt }), // `exec -` reads the prompt from stdin
@@ -2543,6 +2811,7 @@ export const codexHarness = {
2543
2811
  // cwd, so one shared shim serves every worktree.
2544
2812
  shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
2545
2813
  shimScope: 'project',
2814
+ shimOwnership: 'shared-json',
2546
2815
  // a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
2547
2816
  // the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
2548
2817
  // root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
@@ -2789,7 +3058,7 @@ const governedSharedRuntimeOwners = (runtimeDir, descriptorKey, threadId, exclud
2789
3058
  continue;
2790
3059
  let parsed;
2791
3060
  try {
2792
- parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8'));
3061
+ parsed = JSON.parse(readFileSync(join(root, entry.name, 'runtime.json'), 'utf8'));
2793
3062
  }
2794
3063
  catch (error) {
2795
3064
  if (error.code === 'ENOENT')
@@ -2874,9 +3143,15 @@ export const codexHeadlessHarness = {
2874
3143
  runtimeOwnership: 'adapter',
2875
3144
  launchOneShot: true,
2876
3145
  launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
2877
- // Record-backed liveness is the family contract for sleeping headless threads. An explicit stop is the one
2878
- // offline marker; other app-server/thread failures surface through delivery rather than speculative liveness.
2879
- liveness: recordOnline,
3146
+ // A headless thread has no pane to witness it. The shared app-server generation is therefore the adapter's
3147
+ // minimum liveness witness: a stale session record must not keep a dead project runtime online forever. The
3148
+ // session layer adds the one exact loaded-reference census before publishing this reading on the board.
3149
+ liveness: (rec, _tmuxAlive, runtimeDir) => {
3150
+ if (rec.stopped || rec.archived || !rec.harnessSessionId)
3151
+ return 'offline';
3152
+ const endpoint = codexEndpointForRecord(rec, runtimeDir ?? runtimeRoot());
3153
+ return endpoint && codexRuntimeGenerationProof(runtimeDir ?? runtimeRoot(), endpoint) ? 'online' : 'offline';
3154
+ },
2880
3155
  launchReady: async (current, deadline) => {
2881
3156
  for (;;) {
2882
3157
  const proof = await codexHeadlessReadinessProof(current);
@@ -2917,6 +3192,7 @@ export const piHarness = {
2917
3192
  ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
2918
3193
  paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
2919
3194
  executionTrace: readSessionJsonlExecutionTrace,
3195
+ readTranscript: (threadId, range) => unsupportedTranscriptReader('pi', threadId, range),
2920
3196
  launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
2921
3197
  baseCmd: piBaseCmd,
2922
3198
  sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
@@ -2924,6 +3200,7 @@ export const piHarness = {
2924
3200
  launchEnv: rendezvousLaunchEnv,
2925
3201
  shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
2926
3202
  shimScope: 'tree',
3203
+ shimOwnership: 'exclusive',
2927
3204
  worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
2928
3205
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
2929
3206
  skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
@@ -2986,6 +3263,7 @@ export const zcodeHarness = {
2986
3263
  ownsRendezvous: false,
2987
3264
  paneTitleIsSelfSummary: false,
2988
3265
  executionTrace: noExecutionTrace,
3266
+ readTranscript: (threadId, range) => unsupportedTranscriptReader('zcode', threadId, range),
2989
3267
  launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
2990
3268
  baseCmd: zcodeBaseCmd,
2991
3269
  // z-code's one-turn launcher already IS the non-interactive shape; it takes the prompt as an argument.
@@ -2995,6 +3273,7 @@ export const zcodeHarness = {
2995
3273
  launchEnv: noLaunchEnv,
2996
3274
  shimFile: (proj) => join(proj, '.zcode', 'settings.json'),
2997
3275
  shimScope: 'tree',
3276
+ shimOwnership: 'shared-json',
2998
3277
  worktreeHookAnchor: () => null,
2999
3278
  contractFiles: (proj) => [join(proj, 'AGENTS.md')],
3000
3279
  skillDir: (proj) => join(proj, '.zcode', 'skills'),
@@ -3021,6 +3300,7 @@ export const opencodeHarness = {
3021
3300
  ownsRendezvous: true,
3022
3301
  paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
3023
3302
  executionTrace: readLocalStoreExecutionTrace,
3303
+ readTranscript: (threadId, range) => unsupportedTranscriptReader('opencode', threadId, range),
3024
3304
  launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
3025
3305
  baseCmd: opencodeBaseCmd,
3026
3306
  // `opencode run` takes the message positionally; it documents no stdin form, so the prompt is an argument.
@@ -3036,6 +3316,7 @@ export const opencodeHarness = {
3036
3316
  // by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
3037
3317
  shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
3038
3318
  shimScope: 'tree',
3319
+ shimOwnership: 'exclusive',
3039
3320
  worktreeHookAnchor: () => null,
3040
3321
  contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
3041
3322
  skillDir: (proj) => join(proj, '.opencode', 'skills'),