@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/sessions.js CHANGED
@@ -1,31 +1,87 @@
1
- import { execFile, spawn } from 'node:child_process';
1
+ import { execFile, execFileSync, spawn } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { createHash, randomUUID } from 'node:crypto';
4
- import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
4
+ import { readFileSync, writeFileSync, existsSync, renameSync, linkSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs';
5
5
  import { join, dirname, isAbsolute, resolve, sep } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { rm as rmAsync, readdir as readdirAsync } from 'node:fs/promises';
7
8
  import { seedWorktreeHostState } from './worktree-sources.js';
8
- import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
9
+ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, parseStatPath, withGitAbortSignal, isGitObjectId } from '@spexcode/spec-core';
9
10
  import { loadConfig, loadSpecs, loadSpecsLite } from '@spexcode/spec-core';
10
11
  import { adapterLoadedReferenceState, assertRvSockPath, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock } from './harness.js';
11
12
  import { materialize } from './materialize.js';
12
13
  import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal } from '@spexcode/spec-core';
13
14
  import { readSessionFiles } from './session-files.js';
14
15
  import { readSessionWebs } from './session-web.js';
15
- import { acceptMessage, drain, recordStatus, lastHumanSendVia, owesDelivery, pendingMessages } from '@spexcode/session-core';
16
- import { pendingSnapshot, replacePendingWhileLocked, revokePendingFromWhileLocked, revokeSenderDelivery, withDeliveryLocks, trySessionRecordLockSync, withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from '@spexcode/session-core/internal';
16
+ import { acquireFreshSessionApplicationForCreate, configuredSessionApplicationIfCutover, initializeFreshSessionApplication, releaseFreshSessionApplicationForCreate, sessionApplicationCutoverState, setSessionApplicationCommitWake } from './session-application.js';
17
+ import { jsonMigrationFencePath } from '@spexcode/session-application';
18
+ import { withDeliveryLocks } from './delivery-lock.js';
19
+ import { withSessionRecordLock, withSessionRecordLockSync as coreWithSessionRecordLockSync } from './session-record-lock.js';
17
20
  import { stripRefSigil } from './mentions.js';
18
21
  import { shQuote } from './sh.js';
19
22
  import { assertSessionOwnerSafe, assertSessionStopSafe, ResourceConflict } from './host-resources.js';
20
23
  import { processStartToken } from '@spexcode/spec-core';
21
- import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js';
24
+ import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js';
22
25
  import { cliEntrypointArgs } from './tsx-bin.js';
26
+ import { lastHumanSendVia, recordStatus } from './session-timeline.js';
23
27
  const pexec = promisify(execFile);
24
28
  export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode';
25
29
  const DEFER_FOOTPRINT_REFRESH = { SPEXCODE_DEFER_FOOTPRINT_REFRESH: 'session-create' };
26
30
  const HARNESS = defaultHarness;
27
31
  const COLS = 120, ROWS = 32;
28
32
  const DEFAULT_MAX_ACTIVE = 8;
33
+ const worktreeTrashDir = (root) => join(root, '.worktrees', '.trash');
34
+ const pendingTrashDeletes = [];
35
+ let trashDeleteRunning = false;
36
+ let trashDeleteScheduled = false;
37
+ async function drainWorktreeTrash() {
38
+ while (pendingTrashDeletes.length) {
39
+ const path = pendingTrashDeletes.shift();
40
+ try {
41
+ await rmAsync(path, { recursive: true, force: true });
42
+ if (existsSync(path))
43
+ throw new Error('path remains after recursive removal');
44
+ }
45
+ catch (error) {
46
+ console.error(`spex: deferred worktree deletion failed for ${path}; retained for next backend startup: ${error instanceof Error ? error.message : error}`);
47
+ }
48
+ }
49
+ trashDeleteRunning = false;
50
+ }
51
+ function queueWorktreeTrash(path) {
52
+ pendingTrashDeletes.push(path);
53
+ if (trashDeleteRunning || trashDeleteScheduled)
54
+ return;
55
+ trashDeleteScheduled = true;
56
+ setImmediate(() => {
57
+ trashDeleteScheduled = false;
58
+ trashDeleteRunning = true;
59
+ void drainWorktreeTrash();
60
+ });
61
+ }
62
+ /** Start the one process-local serial reaper and resume any crash leftovers. */
63
+ export function startWorktreeTrashReaper() {
64
+ const dir = worktreeTrashDir(mainRoot());
65
+ readdirAsync(dir, { withFileTypes: true }).then((entries) => {
66
+ for (const entry of entries)
67
+ queueWorktreeTrash(join(dir, entry.name));
68
+ }).catch((error) => {
69
+ if (error?.code !== 'ENOENT')
70
+ console.error(`spex: deferred worktree trash cleanup failed for ${dir}; retrying next startup: ${error instanceof Error ? error.message : error}`);
71
+ });
72
+ }
73
+ function moveWorktreeToTrash(root, path) {
74
+ const worktrees = resolve(join(root, '.worktrees'));
75
+ const source = resolve(path);
76
+ // Session creation uses <root>/.worktrees/<name>. Keep an adjacent trash for legacy/manual records whose
77
+ // recorded path predates that layout; the normal product path always lands in the governed .worktrees/.trash.
78
+ const parent = dirname(source);
79
+ const dir = parent === worktrees ? worktreeTrashDir(root) : join(parent, '.trash');
80
+ mkdirSync(dir, { recursive: true });
81
+ const target = join(dir, `wt-${Date.now()}-${randomUUID().slice(0, 12)}`);
82
+ renameSync(source, target);
83
+ return target;
84
+ }
29
85
  function maxActive() {
30
86
  let v;
31
87
  try {
@@ -47,7 +103,7 @@ function maxActive() {
47
103
  // propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
48
104
  // an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
49
105
  // ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
50
- const rvEnv = (id, harness = HARNESS) => {
106
+ const rvEnv = (id, harness = HARNESS, nativeStartToken) => {
51
107
  // SPEXCODE_SESSION_ID is the governed record id, and it is the SESSION'S OWN — so the launch STRIPS every
52
108
  // session-identity variable it may have inherited (the pane inherits the tmux SERVER's env, which may carry
53
109
  // a foreign session's ids from whoever started it) before setting this one. Identity is established HERE,
@@ -65,9 +121,15 @@ const rvEnv = (id, harness = HARNESS) => {
65
121
  `SPEXCODE_SESSION_ID=${id}`,
66
122
  `SPEXCODE_SESSION_IDENTITY_VARS=${shQuote(sessionIdentityEnvVars().join(','))}`,
67
123
  `SPEXCODE_PROJECT_ROOT=${shQuote(mainRoot())}`,
124
+ ...(nativeStartToken ? [`SPEXCODE_NATIVE_START_TOKEN=${shQuote(nativeStartToken)}`] : []),
68
125
  ...harness.launchEnv(id), ...homeVars].join(' ');
69
126
  };
70
127
  const PROPOSAL_STATUS = { merge: 'review', nothing: 'done', close: 'close-pending' };
128
+ // Awaiting is the durable lifecycle row; its proposal selects the user-facing display status. Keep this
129
+ // projection in the session package so backend reconciliation and offline client reads cannot drift apart.
130
+ export function displayStatusForProposal(proposal) {
131
+ return PROPOSAL_STATUS[proposal ?? 'nothing'];
132
+ }
71
133
  function normalizeCloseSource(raw) {
72
134
  if (raw == null)
73
135
  return { kind: 'user' };
@@ -80,64 +142,6 @@ function normalizeCloseSource(raw) {
80
142
  return { kind: 'unverified-session-claim', id: source.id.trim() };
81
143
  throw new ResourceConflict('refusing session close: source must be user or an unverified session claim');
82
144
  }
83
- function appendCloseLedger(id, rec, source) {
84
- const path = join(runtimeRoot(), 'session-close-ledger.ndjson');
85
- const event = {
86
- version: 1,
87
- action: 'close-authorized',
88
- at: new Date().toISOString(),
89
- source,
90
- target: {
91
- id,
92
- harness: rec.harness,
93
- thread: rec.harnessSessionId,
94
- worktree: rec.worktreePath,
95
- branch: rec.branch,
96
- },
97
- };
98
- appendFileSync(path, `${JSON.stringify(event)}\n`);
99
- }
100
- export function findSessionClosure(selector) {
101
- const query = stripRefSigil(selector).trim();
102
- if (!query)
103
- return null;
104
- const path = join(runtimeRoot(), 'session-close-ledger.ndjson');
105
- let text;
106
- try {
107
- text = readFileSync(path, 'utf8');
108
- }
109
- catch (error) {
110
- if (error.code === 'ENOENT')
111
- return null;
112
- throw error;
113
- }
114
- const matches = new Map();
115
- for (const line of text.split('\n')) {
116
- if (!line.trim())
117
- continue;
118
- let event;
119
- try {
120
- event = JSON.parse(line);
121
- }
122
- catch {
123
- throw new ResourceConflict('session close history is unreadable');
124
- }
125
- if (!event || typeof event !== 'object' || event.action !== 'close-authorized')
126
- continue;
127
- const target = event.target;
128
- const id = target && typeof target === 'object' ? target.id : undefined;
129
- const closedAt = event.at;
130
- if (typeof id !== 'string' || !id || typeof closedAt !== 'string' || !closedAt)
131
- throw new ResourceConflict('session close history is malformed');
132
- if (id === query || id.startsWith(query))
133
- matches.set(id, { id, closedAt });
134
- }
135
- if (!matches.size)
136
- return null;
137
- if (matches.size > 1)
138
- throw new ResourceConflict(`close history for ${query} is ambiguous: ${[...matches.keys()].map((id) => id.slice(0, 8)).join(', ')}`);
139
- return [...matches.values()][0];
140
- }
141
145
  function storeDir(id) { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d; }
142
146
  function writePromptFile(id, prompt) {
143
147
  try {
@@ -245,19 +249,57 @@ export function canDrainQueued(rec, authority = backendLaunchAuthority()) {
245
249
  return rec.status === 'queued' && !rec.stopped && (rec.launchOwner === null || rec.launchOwner === authority);
246
250
  }
247
251
  // typed read of a session's record from the global store (null if it has none — a self-launched session that
248
- // only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
252
+ // only ever wrote spec-discipline sentinels has a store dir but no runtime.json). Goes through layout's
249
253
  // readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
250
254
  // on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
251
255
  function readRecord(id) {
252
256
  const entry = readAliasedRecordEntry(id);
253
- if (entry.kind === 'absent')
254
- return null;
257
+ if (entry.kind === 'absent') {
258
+ // A migrated session may retain its canonical application row after an envelope was removed or never
259
+ // materialized. Lifecycle hooks must still reach that row; do not turn missing runtime metadata into a
260
+ // silent "not governed" result. The minimal projection deliberately carries no guessed resource identity.
261
+ const application = configuredSessionApplicationIfCutover();
262
+ const state = application?.readState(id);
263
+ if (!state)
264
+ return null;
265
+ return {
266
+ session: id,
267
+ governed: true,
268
+ worktreePath: '', branch: null, node: null, title: null, name: null, parent: state.parentSessionId,
269
+ status: state.status,
270
+ proposal: isSessionProposal(state.proposal) ? state.proposal : null,
271
+ merges: 0, note: state.note, sortKey: null, createdAt: state.updatedAtMs,
272
+ harness: 'claude', harnessSessionId: null, runtimeStartToken: null,
273
+ stopped: false, archived: false, closedAt: null, coldProof: null, adapterRecovery: null,
274
+ launcher: null, launchCmd: null, launchOwner: null, launchReadinessStartedAt: null,
275
+ createRequestId: null, createPayloadHash: null, zcodeChildSessionIds: [], base: null,
276
+ diffComments: [], launchReadinessPending: null,
277
+ };
278
+ }
255
279
  if (entry.kind === 'corrupt')
256
280
  throw new SessionRecordUnusable('corrupt', id, corruptReason(entry));
257
281
  try {
258
- return fromRaw(entry.raw);
282
+ const record = fromRaw(entry.raw);
283
+ // After cutover, runtime.json is only the runtime/worktree envelope. Lifecycle is owned by the
284
+ // session application. Overlaying here keeps every internal caller on the same fact instead of
285
+ // letting a stale JSON snapshot steer a launch, close, or hook decision.
286
+ const application = configuredSessionApplicationIfCutover();
287
+ if (!application || !record.governed)
288
+ return record;
289
+ const state = application.readState(record.session);
290
+ if (!state)
291
+ throw new ResourceConflict(`session ${record.session} has no canonical application state after JSON cutover`);
292
+ return {
293
+ ...record,
294
+ status: state.status,
295
+ proposal: isSessionProposal(state.proposal) ? state.proposal : null,
296
+ note: state.note,
297
+ parent: state.parentSessionId,
298
+ };
259
299
  }
260
300
  catch (error) {
301
+ if (error instanceof ResourceConflict)
302
+ throw error;
261
303
  throw new SessionRecordUnusable('corrupt', id, `session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`);
262
304
  }
263
305
  }
@@ -275,7 +317,9 @@ const corruptReason = (e) => `session record is unreadable: ${e.path} — ${e.er
275
317
  function retirementReason(rec) {
276
318
  if (!rec.worktreePath || existsSync(rec.worktreePath))
277
319
  return null;
278
- return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched. Close it (\`spex session close <id>\`) to drop the record.`;
320
+ if (rec.archived)
321
+ return `session ${rec.session.slice(0, 8)} is closed and read-only: its worktree ${rec.worktreePath} no longer exists`;
322
+ return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched`;
279
323
  }
280
324
  function readLiveRecord(id) {
281
325
  const rec = readRecord(id);
@@ -291,22 +335,6 @@ export function withSessionRecordLockSync(id, body) {
291
335
  return coreWithSessionRecordLockSync(id, body);
292
336
  }
293
337
  const withRecordLockSync = withSessionRecordLockSync;
294
- // Synchronous terminal input is another product turn-entry path. The PTY bridge uses this narrow seam to
295
- // enqueue input while holding the same durable record lock as archive, so an archive preflight cannot pass idle
296
- // and then race a just-queued TUI turn.
297
- export function withSessionInputLock(id, body) {
298
- // PTY input is synchronous. A single non-blocking open is the only safe barrier: EEXIST rejects this input
299
- // regardless of owner PID, so a same-process async archive can never be frozen behind Atomics.wait.
300
- const release = trySessionRecordLockSync(id);
301
- if (!release)
302
- return null;
303
- try {
304
- return body();
305
- }
306
- finally {
307
- release();
308
- }
309
- }
310
338
  const COLD_PROOF_VERSION = 'cold-v1';
311
339
  function coldProofFor(rec) {
312
340
  const adapter = harnessById(rec.harness || defaultHarness.id).id;
@@ -332,6 +360,9 @@ export function fromRaw(raw) {
332
360
  if (pendingRaw && !pendingStatus)
333
361
  throw new Error(`session '${raw.session_id}' launch readiness original has invalid lifecycle '${pendingRaw.status}'`);
334
362
  const pendingProposal = pendingRaw && isSessionProposal(pendingRaw.proposal) ? pendingRaw.proposal : null;
363
+ if (raw.closed_at != null && raw.closed_at !== ''
364
+ && (typeof raw.closed_at !== 'string' || !Number.isFinite(Date.parse(raw.closed_at))))
365
+ throw new Error(`session '${raw.session_id}' has invalid closed_at`);
335
366
  if (pendingRaw?.proposal && !pendingProposal)
336
367
  throw new Error(`session '${raw.session_id}' launch readiness original has invalid proposal '${pendingRaw.proposal}'`);
337
368
  const zcodeChildSessionIds = raw.zcode_child_session_ids ?? [];
@@ -339,6 +370,18 @@ export function fromRaw(raw) {
339
370
  || zcodeChildSessionIds.some((id) => typeof id !== 'string' || !id || id.trim() !== id)
340
371
  || new Set(zcodeChildSessionIds).size !== zcodeChildSessionIds.length)
341
372
  throw new Error(`session '${raw.session_id}' has invalid zcode_child_session_ids`);
373
+ const diffComments = raw.diff_comments ?? [];
374
+ if (!Array.isArray(diffComments) || diffComments.some((comment) => !comment || typeof comment !== 'object'))
375
+ throw new Error(`session '${raw.session_id}' has invalid diff_comments`);
376
+ const parsedDiffComments = diffComments.map((comment) => {
377
+ const c = comment;
378
+ if (!c.id || typeof c.id !== 'string' || typeof c.file_path !== 'string' || !c.file_path
379
+ || !Number.isInteger(c.line_start) || c.line_start < 1 || !Number.isInteger(c.line_end) || c.line_end < c.line_start
380
+ || typeof c.body !== 'string' || !c.body.trim() || typeof c.diff_identity !== 'string'
381
+ || !(c.sent_at === null || typeof c.sent_at === 'string'))
382
+ throw new Error(`session '${raw.session_id}' has invalid diff comment`);
383
+ return { id: c.id, filePath: c.file_path, lineStart: c.line_start, lineEnd: c.line_end, body: c.body, diffIdentity: c.diff_identity, sentAt: c.sent_at };
384
+ });
342
385
  return {
343
386
  session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
344
387
  node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
@@ -346,23 +389,30 @@ export function fromRaw(raw) {
346
389
  note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
347
390
  harness: raw.harness || 'claude', // records written before the harness field default to claude
348
391
  harnessSessionId: raw.harness_session_id || null,
392
+ runtimeStartToken: raw.runtime_start_token || null,
349
393
  stopped: !!raw.stopped, // records written before explicit stop tracking were not stopped
350
- archived: !!raw.archived, // records written before archive → absent → not shelved
394
+ archived: !!raw.archived, // records written before close retention → absent → working
395
+ closedAt: typeof raw.closed_at === 'string' && raw.closed_at ? raw.closed_at : null,
351
396
  coldProof: raw.cold_proof || null, // legacy archived rows have no proof and remain visible until re-archived
352
397
  adapterRecovery: raw.adapter_recovery || null,
353
398
  launcher: raw.launcher || null, // records written before launchers → null → old-record fallback
354
399
  launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
355
400
  launchOwner: launchOwner || null,
401
+ launchReadinessStartedAt: Number.isFinite(Number(raw.launch_readiness_started_at))
402
+ ? Number(raw.launch_readiness_started_at) : null,
356
403
  createRequestId: raw.create_request_id || null,
357
404
  createPayloadHash: raw.create_payload_hash || null,
358
405
  zcodeChildSessionIds: [...zcodeChildSessionIds],
359
406
  base: raw.base || null, // records written before pinned bases → null → the source-of-truth branch
407
+ forkCommit: raw.fork_commit || null, // records written before the fork commit → null → recovered from the branch reflog
408
+ diffComments: parsedDiffComments,
360
409
  launchReadinessPending: pendingRaw ? {
361
410
  version: 1,
362
411
  startedAt: raw.launch_readiness_pending.startedAt,
363
412
  original: {
364
413
  status: pendingStatus, proposal: pendingProposal, note: pendingRaw.note || null,
365
414
  stopped: pendingRaw.stopped, archived: pendingRaw.archived,
415
+ closedAt: pendingRaw.closed_at || null,
366
416
  coldProof: pendingRaw.cold_proof || null, adapterRecovery: pendingRaw.adapter_recovery || null,
367
417
  },
368
418
  } : null,
@@ -382,6 +432,7 @@ function launchReadinessPending(original) {
382
432
  note: original.note,
383
433
  stopped: original.stopped,
384
434
  archived: original.archived,
435
+ closedAt: original.closedAt,
385
436
  coldProof: original.coldProof ?? null,
386
437
  adapterRecovery: original.adapterRecovery ?? null,
387
438
  },
@@ -392,12 +443,42 @@ function restoreLaunchReadinessOriginal(rec) {
392
443
  return original ? { ...rec, ...original, launchReadinessPending: null } : rec;
393
444
  }
394
445
  // Rebuild the full disk projection so retired keys disappear on the next write.
446
+ function assertLegacyJsonWritesAllowed() {
447
+ const fence = jsonMigrationFencePath(join(runtimeRoot(), 'sessions'));
448
+ if (existsSync(fence) && !configuredSessionApplicationIfCutover()) {
449
+ throw new ResourceConflict(`legacy JSON session store is fenced for one-time migration: ${fence}`);
450
+ }
451
+ }
395
452
  function writeRecord(rec) {
453
+ assertLegacyJsonWritesAllowed();
454
+ const application = configuredSessionApplicationIfCutover();
455
+ // The JSON file is runtime/worktree metadata after cutover, not a lifecycle store. Once the canonical row
456
+ // exists, omit the four old lifecycle keys entirely; retaining them would leave a second apparent fact for
457
+ // readers and tempt a future path to trust the wrong writer. New records still need the legacy shape until
458
+ // their canonical row is created, and non-governed external runtime records keep their own contract.
459
+ const envelope = application && rec.governed ? readAliasedRecordEntry(rec.session) : null;
460
+ const canonicalMetadataOnly = envelope?.kind === 'ok' && rec.governed && !!application;
461
+ const lifecycle = { status: rawLifecycleStatus(rec), proposal: rec.proposal, note: rec.note, parent: rec.parent };
462
+ // A queued legacy envelope may still carry its lease until this metadata rewrite. The lease is an
463
+ // operational launch claim, not a lifecycle fact, so preserve only that field while the typed record clears it.
464
+ const envelopeLaunchOwner = envelope?.kind === 'ok'
465
+ ? envelope.raw.launch_owner?.trim() || null
466
+ : null;
396
467
  let previous = null;
397
468
  try {
398
469
  previous = readRecord(rec.session);
399
470
  }
400
471
  catch { /* a new or damaged record has no prior transition */ }
472
+ const metadataChanged = !previous || [
473
+ 'governed', 'worktreePath', 'branch', 'node', 'title', 'name', 'merges', 'sortKey', 'createdAt',
474
+ 'harness', 'harnessSessionId', 'runtimeStartToken', 'stopped', 'archived', 'closedAt', 'coldProof',
475
+ 'adapterRecovery', 'launcher', 'launchCmd', 'launchOwner', 'launchReadinessStartedAt', 'createRequestId',
476
+ 'createPayloadHash', 'zcodeChildSessionIds', 'base', 'forkCommit', 'diffComments', 'launchReadinessPending',
477
+ ].some((key) => JSON.stringify(previous[key]) !== JSON.stringify(rec[key]));
478
+ // Once a canonical row exists, a lifecycle-only write is already complete when the application transition
479
+ // commits. Rewriting runtime.json here would recreate a second, stale status/proposal/note authority.
480
+ if (canonicalMetadataOnly && previous && !metadataChanged)
481
+ return;
401
482
  const obj = {
402
483
  session_id: rec.session,
403
484
  governed: rec.governed,
@@ -406,28 +487,37 @@ function writeRecord(rec) {
406
487
  node: rec.node ?? '',
407
488
  title: rec.title ?? '',
408
489
  name: rec.name ?? '',
409
- parent: rec.parent ?? '',
410
- status: rawLifecycleStatus(rec),
411
- proposal: rec.proposal ?? '',
412
490
  merges: rec.merges,
413
- note: rec.note ?? '',
414
491
  sortkey: rec.sortKey ?? '',
415
492
  createdAt: rec.createdAt,
416
493
  harness: rec.harness || 'claude',
417
494
  harness_session_id: rec.harnessSessionId ?? '',
418
495
  stopped: rec.stopped,
419
496
  archived: rec.archived,
497
+ // Pre-field records stay byte-shape compatible until a real close publishes the timestamp. In particular,
498
+ // a failed resume must be able to restore an old working record without inventing an empty metadata key.
499
+ ...(rec.closedAt ? { closed_at: rec.closedAt } : {}),
420
500
  cold_proof: rec.coldProof ?? '',
421
501
  adapter_recovery: rec.adapterRecovery ?? '',
422
502
  launcher: rec.launcher ?? '',
423
503
  launch_cmd: rec.launchCmd ?? '',
424
- launch_owner: rec.status === 'queued' ? rec.launchOwner ?? '' : '',
504
+ launch_owner: (lifecycle.status === 'queued' || lifecycle.status === OWNED_QUEUE_RAW_STATUS)
505
+ ? rec.launchOwner ?? envelopeLaunchOwner ?? '' : '',
506
+ ...(rec.launchReadinessStartedAt ? { launch_readiness_started_at: rec.launchReadinessStartedAt } : {}),
507
+ ...(rec.runtimeStartToken ? { runtime_start_token: rec.runtimeStartToken } : {}),
425
508
  create_request_id: rec.createRequestId ?? '',
426
509
  create_payload_hash: rec.createPayloadHash ?? '',
427
510
  ...(rec.zcodeChildSessionIds?.length ? { zcode_child_session_ids: rec.zcodeChildSessionIds } : {}),
428
511
  // Written only when the creator pinned one: an unpinned record keeps its exact legacy bytes, so a
429
512
  // restore-the-frozen-record path stays byte-identical instead of silently gaining a key.
430
513
  ...(rec.base ? { base: rec.base } : {}),
514
+ // The commit `git worktree add` actually started from, written on every create since it was introduced.
515
+ // Conditional like `base` above, so a record written before it keeps its exact legacy bytes.
516
+ ...(rec.forkCommit ? { fork_commit: rec.forkCommit } : {}),
517
+ ...((rec.diffComments ?? []).length ? { diff_comments: (rec.diffComments ?? []).map((comment) => ({
518
+ id: comment.id, file_path: comment.filePath, line_start: comment.lineStart, line_end: comment.lineEnd,
519
+ body: comment.body, diff_identity: comment.diffIdentity, sent_at: comment.sentAt,
520
+ })) } : {}),
431
521
  launch_readiness_pending: rec.launchReadinessPending ? {
432
522
  version: 1,
433
523
  startedAt: rec.launchReadinessPending.startedAt,
@@ -437,104 +527,62 @@ function writeRecord(rec) {
437
527
  note: rec.launchReadinessPending.original.note ?? '',
438
528
  stopped: rec.launchReadinessPending.original.stopped,
439
529
  archived: rec.launchReadinessPending.original.archived,
530
+ closed_at: rec.launchReadinessPending.original.closedAt,
440
531
  cold_proof: rec.launchReadinessPending.original.coldProof ?? '',
441
532
  adapter_recovery: rec.launchReadinessPending.original.adapterRecovery ?? '',
442
533
  },
443
534
  } : '',
535
+ ...(canonicalMetadataOnly ? {} : {
536
+ parent: lifecycle.parent ?? '',
537
+ status: lifecycle.status,
538
+ proposal: lifecycle.proposal ?? '',
539
+ note: lifecycle.note ?? '',
540
+ }),
444
541
  };
445
542
  const dir = sessionStoreDir(rec.session);
446
543
  mkdirSync(dir, { recursive: true });
447
544
  const path = sessionRecordPath(rec.session);
448
- const tmp = join(dir, `.session.json.${process.pid}.tmp`);
545
+ const tmp = join(dir, `.runtime.json.${process.pid}.tmp`);
449
546
  writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
450
547
  renameSync(tmp, path); // atomic within the dir: a concurrent reader sees the old record or the new one
451
548
  const previousPublic = previous ? publicRecord(previous) : null;
452
549
  const nextPublic = publicRecord(rec);
453
- if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
550
+ if (!application && rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
454
551
  || previousPublic.proposal !== nextPublic.proposal || previousPublic.note !== nextPublic.note)) {
455
552
  recordStatus(rec.session, nextPublic.status, nextPublic.proposal, nextPublic.note);
456
553
  scheduleWatchNotifications(rec);
457
554
  }
458
555
  }
459
- const watchPath = (target) => sessionArtifactPath(target, 'watchers.json');
460
- function readWatchEntries(target) {
461
- try {
462
- const raw = JSON.parse(readFileSync(watchPath(target), 'utf8'));
463
- if (!Array.isArray(raw))
464
- return [];
465
- const parent = readRecord(target)?.parent ?? '';
466
- const seen = new Set();
467
- return raw.flatMap((entry) => {
468
- if (!entry || typeof entry !== 'object')
469
- return [];
470
- const watcher = entry.watcher;
471
- const createdAt = entry.createdAt;
472
- if (!watcher || typeof watcher !== 'string' || typeof createdAt !== 'string' || seen.has(watcher))
473
- return [];
474
- seen.add(watcher);
475
- const rawSources = entry.sources;
476
- const sources = Array.isArray(rawSources)
477
- ? [...new Set(rawSources.filter((source) => source === 'manual' || source === 'parent'))]
478
- // The former one-source format cannot name an origin. Its child pointer is the only durable witness
479
- // that this watcher was installed for parent supervision; every other legacy row is a manual watch.
480
- : [watcher === parent ? 'parent' : 'manual'];
481
- const snapshotPending = entry.snapshotPending;
482
- return sources.length ? [{ watcher, createdAt, sources,
483
- ...(sources.includes('parent') && typeof snapshotPending === 'string' && snapshotPending ? { snapshotPending } : {}),
484
- }] : [];
485
- });
486
- }
487
- catch {
556
+ function canonicalWatchEntries(target) {
557
+ const application = configuredSessionApplicationIfCutover();
558
+ if (!application)
559
+ return null;
560
+ if (!application.readState(target))
488
561
  return [];
489
- }
490
- }
491
- function writeWatchEntries(target, entries) {
492
- const path = watchPath(target);
493
- if (!entries.length) {
494
- try {
495
- unlinkSync(path);
562
+ const seen = new Map();
563
+ for (const edge of application.topology.parents(target)) {
564
+ if (edge.relationType !== 'parent' && !edge.relationType.startsWith('watch'))
565
+ continue;
566
+ const source = edge.relationType === 'parent' || edge.relationType === 'watch:parent' ? 'parent' : 'manual';
567
+ const current = seen.get(edge.fromSessionId);
568
+ if (current) {
569
+ if (!current.sources.includes(source))
570
+ current.sources.push(source);
571
+ continue;
496
572
  }
497
- catch { /* already absent */ }
498
- ;
499
- return;
573
+ seen.set(edge.fromSessionId, {
574
+ watcher: edge.fromSessionId,
575
+ createdAt: new Date(edge.createdAtMs).toISOString(),
576
+ sources: [source],
577
+ });
500
578
  }
501
- const dir = sessionStoreDir(target);
502
- mkdirSync(dir, { recursive: true });
503
- const tmp = join(dir, `.watchers.json.${process.pid}.tmp`);
504
- writeFileSync(tmp, JSON.stringify(entries, null, 2) + '\n');
505
- renameSync(tmp, path);
506
- }
507
- function addWatchSource(entries, watcher, source, deferInitialSnapshot = false) {
508
- const existing = entries.find((entry) => entry.watcher === watcher);
509
- const snapshotPending = deferInitialSnapshot && !existing?.sources.includes('manual') ? randomUUID() : undefined;
510
- if (!existing)
511
- return { entries: [...entries, {
512
- watcher, createdAt: new Date().toISOString(), sources: [source], ...(snapshotPending ? { snapshotPending } : {}),
513
- }], added: true };
514
- if (existing.sources.includes(source))
515
- return { entries, added: false };
516
- return {
517
- entries: entries.map((entry) => entry === existing ? {
518
- ...entry, sources: [...entry.sources, source], ...(snapshotPending ? { snapshotPending } : {}),
519
- } : entry),
520
- added: true,
521
- };
579
+ return [...seen.values()].sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.watcher.localeCompare(b.watcher));
522
580
  }
523
- function removeWatchSource(entries, watcher, source) {
524
- let removed = false;
525
- const next = entries.flatMap((entry) => {
526
- if (entry.watcher !== watcher || !entry.sources.includes(source))
527
- return [entry];
528
- removed = true;
529
- const sources = entry.sources.filter((candidate) => candidate !== source);
530
- if (!sources.length)
531
- return [];
532
- if (source !== 'parent')
533
- return [{ ...entry, sources }];
534
- const { snapshotPending: _pending, ...withoutParentDebt } = entry;
535
- return [{ ...withoutParentDebt, sources }];
536
- });
537
- return { entries: next, removed };
581
+ function readWatchEntries(target) {
582
+ const canonical = canonicalWatchEntries(target);
583
+ if (canonical)
584
+ return canonical;
585
+ throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
538
586
  }
539
587
  function managedWatchRecord(id) {
540
588
  const rec = readRecord(id);
@@ -544,7 +592,7 @@ function managedWatchRecord(id) {
544
592
  }
545
593
  function watchMessage(target) {
546
594
  const status = target.status === 'awaiting'
547
- ? PROPOSAL_STATUS[target.proposal ?? 'nothing']
595
+ ? displayStatusForProposal(target.proposal)
548
596
  : target.status === 'active' ? 'working' : target.status;
549
597
  const note = target.note ? ` — ${target.note}` : '';
550
598
  return `[spex watch] ${target.session} is ${status}${note}`;
@@ -555,180 +603,81 @@ function shouldDeliverWatchTransition(target, sources) {
555
603
  }
556
604
  function scheduleWatchNotifications(target) {
557
605
  const watchers = readWatchEntries(target.session)
558
- .filter((entry) => !entry.snapshotPending && shouldDeliverWatchTransition(target, entry.sources))
606
+ .filter((entry) => shouldDeliverWatchTransition(target, entry.sources))
559
607
  .map((entry) => entry.watcher);
560
608
  if (!watchers.length)
561
609
  return;
562
610
  queueMicrotask(() => {
563
611
  for (const watcher of watchers) {
564
- void sendText(watcher, watchMessage(target), target.session).then((result) => {
612
+ void sendText(watcher, watchMessage(target), target.session, { allowStranded: true }).then((result) => {
565
613
  if (!result.ok)
566
614
  console.error(`spex session watch: could not deliver ${target.session} state to ${watcher}: ${result.error}`);
567
615
  });
568
616
  }
569
617
  });
570
618
  }
571
- const watchSnapshotState = (target) => JSON.stringify([target.status, target.proposal, target.note]);
572
- async function deliverPendingWatchSnapshots(targetId, forceCurrent = true) {
573
- const pending = readWatchEntries(targetId).filter((entry) => entry.snapshotPending);
574
- for (const original of pending) {
575
- const token = original.snapshotPending;
576
- let force = forceCurrent;
577
- for (;;) {
578
- const target = readRecord(targetId);
579
- const entry = readWatchEntries(targetId)
580
- .find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
581
- if (!target || !entry)
582
- break;
583
- const state = watchSnapshotState(target);
584
- const shouldDeliver = force || shouldDeliverWatchTransition(target, entry.sources);
585
- if (!shouldDeliver) {
586
- let settled = false;
587
- await withRecordLock(targetId, async () => {
588
- const current = readRecord(targetId);
589
- const entries = readWatchEntries(targetId);
590
- const pendingEntry = entries.find((candidate) => candidate.watcher === original.watcher && candidate.snapshotPending === token);
591
- if (!current || !pendingEntry || watchSnapshotState(current) !== state)
592
- return;
593
- const next = entries.map((candidate) => {
594
- if (candidate !== pendingEntry)
595
- return candidate;
596
- const { snapshotPending: _pending, ...cleared } = candidate;
597
- return cleared;
598
- });
599
- writeWatchEntries(targetId, next);
600
- settled = true;
601
- });
602
- if (settled)
603
- break;
604
- force = false;
605
- continue;
606
- }
607
- const identity = `${targetId}\0${entry.watcher}\0${token}\0${state}`;
608
- const delivered = await sendText(entry.watcher, watchMessage(target), targetId, {
609
- idempotency: {
610
- operation: 'watch-initial-snapshot',
611
- requestDigest: digest(identity),
612
- payloadHash: digest(`watch-initial-snapshot\0${identity}\0${watchMessage(target)}`),
613
- },
614
- acceptGuard: async () => {
615
- const current = readRecord(targetId);
616
- const stillPending = readWatchEntries(targetId)
617
- .some((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
618
- if (!current || !stillPending || watchSnapshotState(current) !== state)
619
- throw new ResourceConflict('watch initial snapshot changed before acceptance');
620
- },
621
- });
622
- if (!delivered.ok) {
623
- if (delivered.error?.includes('watch initial snapshot changed before acceptance'))
624
- continue;
625
- console.error(`spex session watch: could not deliver initial ${targetId} state to ${entry.watcher}: ${delivered.error}`);
626
- break;
627
- }
628
- force = false;
629
- let settled = false;
630
- await withRecordLock(targetId, async () => {
631
- const current = readRecord(targetId);
632
- const entries = readWatchEntries(targetId);
633
- const pendingEntry = entries.find((candidate) => candidate.watcher === entry.watcher && candidate.snapshotPending === token);
634
- if (!current || !pendingEntry) {
635
- settled = true;
636
- return;
637
- }
638
- const currentState = watchSnapshotState(current);
639
- if (currentState !== state && shouldDeliverWatchTransition(current, pendingEntry.sources))
640
- return;
641
- const next = entries.map((candidate) => {
642
- if (candidate !== pendingEntry)
643
- return candidate;
644
- const { snapshotPending: _pending, ...cleared } = candidate;
645
- return cleared;
646
- });
647
- writeWatchEntries(targetId, next);
648
- settled = true;
649
- });
650
- if (settled)
651
- break;
652
- }
653
- }
654
- }
655
- async function clearPendingWatchSnapshots(targetId) {
656
- await withRecordLock(targetId, async () => {
657
- const entries = readWatchEntries(targetId);
658
- const next = entries.map((entry) => {
659
- if (!entry.snapshotPending)
660
- return entry;
661
- const { snapshotPending: _pending, ...settled } = entry;
662
- return settled;
663
- });
664
- if (next.some((entry, index) => entry !== entries[index]))
665
- writeWatchEntries(targetId, next);
666
- });
667
- }
668
619
  export async function subscribeSessionWatch(watcher, targets, source = 'manual') {
669
620
  managedWatchRecord(watcher);
621
+ const application = configuredSessionApplicationIfCutover();
622
+ if (!application)
623
+ throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
670
624
  const watched = [];
625
+ const channel = source === 'parent' ? 'watch:parent' : 'watch:manual';
671
626
  for (const target of [...new Set(targets)]) {
672
627
  if (target === watcher)
673
628
  throw new ResourceConflict('a session cannot watch itself');
674
- let targetRecord = null;
675
- let added = false;
676
- let pending = false;
677
- withRecordLockSync(target, () => {
678
- targetRecord = managedWatchRecord(target);
679
- const entries = readWatchEntries(target);
680
- const next = addWatchSource(entries, watcher, source, source === 'parent' && targetRecord.status === 'queued');
681
- if (next.added)
682
- writeWatchEntries(target, next.entries);
683
- added = next.added;
684
- pending = next.entries.some((entry) => entry.watcher === watcher && !!entry.snapshotPending);
685
- });
686
- if (pending) {
687
- if (source === 'manual')
688
- await deliverPendingWatchSnapshots(target);
629
+ const targetRecord = managedWatchRecord(target);
630
+ try {
631
+ application.attachWatcher(watcher, target, channel);
689
632
  }
690
- else if (source === 'manual' || added) {
691
- const delivered = await sendText(watcher, watchMessage(targetRecord), target);
692
- if (!delivered.ok)
693
- throw new ResourceConflict(`watch established but could not queue ${target}'s current state for ${watcher}: ${delivered.error}`);
633
+ catch (error) {
634
+ if (!(error instanceof Error) || !/already exists|duplicate|active topology edge/i.test(error.message))
635
+ throw error;
694
636
  }
637
+ const message = watchMessage(targetRecord);
638
+ application.enqueueMessage(watcher, {
639
+ kind: 'session.prompt.v1',
640
+ body: Buffer.from(message, 'utf8'),
641
+ senderSessionId: target,
642
+ idempotencyKey: digest(`watch-initial-snapshot\0${watcher}\0${target}\0${source}\0${message}`),
643
+ });
695
644
  watched.push(target);
696
645
  }
697
646
  return { watched };
698
647
  }
699
648
  export function listSessionWatches(watcher) {
700
649
  managedWatchRecord(watcher);
701
- const watches = [];
702
- for (const target of listSessionIds()) {
703
- const entries = readWatchEntries(target);
704
- const active = entries.filter((entry) => {
705
- try {
706
- return !!readRecord(entry.watcher)?.governed;
707
- }
708
- catch {
709
- return false;
710
- }
711
- });
712
- if (active.length !== entries.length)
713
- writeWatchEntries(target, active);
714
- for (const entry of active)
715
- if (entry.watcher === watcher)
716
- watches.push({ target, createdAt: entry.createdAt });
717
- }
718
- return watches.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target));
650
+ const application = configuredSessionApplicationIfCutover();
651
+ if (!application)
652
+ throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
653
+ // `listWatchers` defaults to the bare `watch` channel. Canonical policy stores the source
654
+ // (`watch:parent`/`watch:manual`) in the relation type, so listing must inspect every watch
655
+ // channel or a valid parent watch appears to have disappeared.
656
+ const edges = ['watch', 'watch:parent', 'watch:manual']
657
+ .flatMap(channel => application.listWatchers(watcher, channel))
658
+ .filter((edge, index, all) => all.findIndex(other => other.toSessionId === edge.toSessionId) === index);
659
+ return edges
660
+ .map(edge => ({ target: edge.toSessionId, createdAt: new Date(edge.createdAtMs).toISOString() }))
661
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.target.localeCompare(b.target));
719
662
  }
720
663
  export function cancelSessionWatch(watcher, targets) {
721
664
  managedWatchRecord(watcher);
665
+ const application = configuredSessionApplicationIfCutover();
666
+ if (!application)
667
+ throw new ResourceConflict('session application is unavailable; refusing the legacy watcher path');
722
668
  let cancelled = 0;
723
669
  for (const target of [...new Set(targets)]) {
724
- withRecordLockSync(target, () => {
725
- const entries = readWatchEntries(target);
726
- const next = removeWatchSource(entries, watcher, 'manual');
727
- if (next.removed) {
728
- writeWatchEntries(target, next.entries);
670
+ for (const channel of ['watch:manual', 'watch']) {
671
+ try {
672
+ application.detachWatcher(watcher, target, channel);
729
673
  cancelled++;
674
+ break;
730
675
  }
731
- });
676
+ catch (error) {
677
+ if (!(error instanceof Error) || !/does not exist|unknown/i.test(error.message))
678
+ throw error;
679
+ }
680
+ }
732
681
  }
733
682
  return cancelled;
734
683
  }
@@ -762,65 +711,65 @@ function assertReparentable(children, parent, records) {
762
711
  }
763
712
  export async function reparentSessionRecords(rawChildren, parent) {
764
713
  const children = [...new Set(rawChildren)].sort();
714
+ const application = configuredSessionApplicationIfCutover();
715
+ if (!application)
716
+ throw new ResourceConflict('session application is unavailable; refusing the legacy reparent path');
765
717
  const notify = [];
766
718
  await withRecordLock('session-reparent-transaction', async () => {
767
- // Read former supervisors only after the transaction fence: a concurrent reparent may change exactly
768
- // this relation, and its real sender lock is part of the next transaction's outgoing-message boundary.
769
719
  const before = new Map(children.map((id) => [id, managedWatchRecord(id)]));
770
720
  assertReparentable(children, parent, before);
771
721
  const formerParents = [...new Set([...before.values()].flatMap((record) => record.parent ? [record.parent] : []))];
772
722
  await withRecordLocks([...children, ...formerParents].sort(), () => withDeliveryLocks(children, async () => {
773
723
  const current = new Map(children.map((id) => [id, managedWatchRecord(id)]));
774
724
  assertReparentable(children, parent, current);
775
- const snapshots = children.map((id) => ({ id, record: current.get(id), watchers: readWatchEntries(id), pending: pendingSnapshot(id) }));
776
- try {
777
- for (const snapshot of snapshots) {
778
- const { record, watchers } = snapshot;
779
- const hadNewParent = !!parent && watchers.some((entry) => entry.watcher === parent && entry.sources.includes('parent'));
780
- const withoutFormerParent = record.parent
781
- ? removeWatchSource(watchers, record.parent, 'parent').entries
782
- : watchers;
783
- const nextWatchers = parent
784
- ? addWatchSource(withoutFormerParent, parent, 'parent').entries
785
- : withoutFormerParent;
786
- if (nextWatchers !== watchers)
787
- writeWatchEntries(snapshot.id, nextWatchers);
788
- if (record.parent !== parent)
789
- writeRecord({ ...record, parent });
790
- if (parent && (record.parent !== parent || !hadNewParent))
791
- notify.push({ ...record, parent });
792
- }
793
- for (const snapshot of snapshots) {
794
- if (snapshot.record.parent && snapshot.record.parent !== parent)
795
- revokePendingFromWhileLocked(snapshot.id, snapshot.record.parent);
725
+ for (const [id, record] of current) {
726
+ const state = application.readState(id);
727
+ if (!state)
728
+ throw new ResourceConflict(`session ${id} has no canonical application state during reparent`);
729
+ if (state.parentSessionId !== record.parent) {
730
+ throw new ResourceConflict(`session ${id} canonical/record parent mismatch: record=${record.parent ?? 'null'} canonical=${state.parentSessionId ?? 'null'}`);
796
731
  }
797
732
  }
798
- catch (error) {
799
- let rollbackFailure = null;
800
- for (const snapshot of [...snapshots].reverse()) {
733
+ for (const [id, record] of current) {
734
+ if (record.parent === parent)
735
+ continue;
736
+ const change = application.transitionSession(id, { parentSessionId: parent, reason: 'reparent' });
737
+ if (record.parent) {
738
+ try {
739
+ application.detachWatcher(record.parent, id, 'watch:parent');
740
+ }
741
+ catch (error) {
742
+ if (!(error instanceof Error) || !/does not exist|unknown/i.test(error.message))
743
+ throw error;
744
+ }
745
+ for (const message of application.readPendingMessages(id)) {
746
+ if (message.senderSessionId === record.parent)
747
+ application.dequeuePendingMessage(id, message.messageId);
748
+ }
749
+ }
750
+ if (parent) {
801
751
  try {
802
- replacePendingWhileLocked(snapshot.id, snapshot.pending);
803
- writeWatchEntries(snapshot.id, snapshot.watchers);
804
- writeRecord(snapshot.record);
752
+ application.attachWatcher(parent, id, 'watch:parent');
805
753
  }
806
- catch (rollback) {
807
- rollbackFailure ??= rollback;
754
+ catch (error) {
755
+ if (!(error instanceof Error) || !/already exists|duplicate|active topology edge/i.test(error.message))
756
+ throw error;
808
757
  }
758
+ // The transition above published to the OLD watcher set. The new supervisor learns the child's current
759
+ // state here, keyed by that transition's own event so a retried rewrite never sends it twice.
760
+ const moved = { ...record, parent };
761
+ application.enqueueMessage(parent, {
762
+ kind: 'session.prompt.v1',
763
+ body: Buffer.from(watchMessage(moved), 'utf8'),
764
+ senderSessionId: id,
765
+ idempotencyKey: digest(`reparent-snapshot\0${change.event.eventId}`),
766
+ });
767
+ notify.push(moved);
809
768
  }
810
- const detail = error instanceof Error ? error.message : String(error);
811
- const rollbackDetail = rollbackFailure instanceof Error ? `; rollback also failed: ${rollbackFailure.message}` : '';
812
- throw new ResourceConflict(`reparent did not commit: ${detail}${rollbackDetail}`);
813
769
  }
814
770
  }));
815
771
  });
816
- const notified = [];
817
- if (parent)
818
- for (const child of notify) {
819
- const delivered = await sendText(parent, watchMessage(child), child.session);
820
- if (!delivered.ok)
821
- throw new ResourceConflict(`reparent committed but could not queue ${child.session}'s current state for ${parent}: ${delivered.error}`);
822
- notified.push(child.session);
823
- }
772
+ const notified = notify.map((child) => child.session);
824
773
  return { children, parent, notified };
825
774
  }
826
775
  // tmux rewrites CONTROL characters in a format string before printing them — 3.6a turns both a tab and a raw
@@ -1083,18 +1032,18 @@ export function liveness(rec, snap) {
1083
1032
  return 'unknown';
1084
1033
  return 'offline';
1085
1034
  }
1086
- function reconcile(rec, snap) {
1035
+ function reconcile(rec, snap, residentLiveness) {
1087
1036
  // record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
1088
1037
  // about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
1089
- if (retirementReason(rec))
1090
- return 'retired';
1091
1038
  if (rec.archived)
1092
1039
  return 'offline';
1040
+ if (retirementReason(rec))
1041
+ return 'retired';
1093
1042
  if (rec.status === 'awaiting')
1094
- return PROPOSAL_STATUS[rec.proposal || 'nothing'];
1043
+ return displayStatusForProposal(rec.proposal);
1095
1044
  if (rec.status !== 'active' && rec.status !== 'idle')
1096
1045
  return rec.status; // parked | error | asking | queued (no tmux yet)
1097
- const lv = liveness(rec, snap);
1046
+ const lv = residentLiveness ?? liveness(rec, snap);
1098
1047
  if (lv !== 'online')
1099
1048
  return lv; // 'offline' | 'starting' | 'unknown'
1100
1049
  return rec.status === 'idle' ? 'idle' : 'working';
@@ -1119,13 +1068,26 @@ export function reviewIdentity(id) {
1119
1068
  label: deriveLabel({ id, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch }),
1120
1069
  };
1121
1070
  }
1071
+ export function sessionHookState(id) {
1072
+ const rec = readRecord(id);
1073
+ if (!rec)
1074
+ return null;
1075
+ const application = configuredSessionApplicationIfCutover();
1076
+ const state = application?.readState(id);
1077
+ return {
1078
+ governed: rec.governed,
1079
+ status: (state?.status ?? rec.status),
1080
+ proposal: (state?.proposal || rec.proposal || null),
1081
+ note: state?.note ?? (rec.note || null),
1082
+ };
1083
+ }
1122
1084
  function corruptSession(id, entry) {
1123
1085
  const label = `${id.slice(0, 8)} (unreadable record)`;
1124
1086
  return {
1125
1087
  id, node: null, branch: null, path: '', label, title: label, raw: { name: null, title: null },
1126
1088
  parent: null, harness: defaultHarness.id, capabilities: { headless: false }, launcher: null,
1127
1089
  lifecycle: 'active', proposal: null, merges: 0, status: 'corrupt', liveness: 'unknown',
1128
- note: corruptReason(entry), archived: false, prompt: null, promptPreview: null, created: 0,
1090
+ note: corruptReason(entry), archived: false, closedAt: null, prompt: null, promptPreview: null, created: 0,
1129
1091
  activity: null, sortKey: null, archiveHazard: null, files: [], web: [],
1130
1092
  };
1131
1093
  }
@@ -1138,7 +1100,7 @@ export function toSession(rec, status, lv, activity = null) {
1138
1100
  const pp = prompt ? oneLinePreview(prompt) : null;
1139
1101
  const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, note: rec.note, promptPreview: pp };
1140
1102
  const harness = harnessById(rec.harness || defaultHarness.id);
1141
- return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
1103
+ return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), title: deriveTitle(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, closedAt: rec.archived ? rec.closedAt : null, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey, files: readSessionFiles(rec.session), web: readSessionWebs(rec.session), ...(rec.zcodeChildSessionIds?.length ? { zcodeChildSessionIds: [...rec.zcodeChildSessionIds] } : {}) };
1142
1104
  }
1143
1105
  // @@@zcode child identity - ZCode owns the child id and SpexCode owns the session record. The writer accepts
1144
1106
  // only their exact declared pair; names, worktrees, branches, and timestamps are deliberately not candidates.
@@ -1197,6 +1159,44 @@ export async function sessionPrompt(id) {
1197
1159
  throw e;
1198
1160
  }
1199
1161
  }
1162
+ // The archive overlay has no reader for the session model. Keep this projection separate from listSessions so
1163
+ // opening it skips the live tmux census, resident adapter probes, and files/web reads, and carries no full prompt bytes.
1164
+ export async function listArchivedSessionIndex(probe) {
1165
+ const rows = [];
1166
+ for (const id of listSessionIds()) {
1167
+ let entry;
1168
+ try {
1169
+ entry = readPublicRecordEntry(id);
1170
+ }
1171
+ catch {
1172
+ continue;
1173
+ }
1174
+ if (entry.kind !== 'ok')
1175
+ continue;
1176
+ const rec = fromRaw(entry.raw);
1177
+ if (!rec.governed || !rec.archived)
1178
+ continue;
1179
+ const parts = {
1180
+ id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch,
1181
+ activity: null, note: rec.note, promptPreview: null,
1182
+ };
1183
+ // Name and note are ahead of the prompt in deriveTitle's precedence. Avoid touching the prompt artifact
1184
+ // unless both are absent; only then can its preview change the visible title.
1185
+ if (!rec.name && !rec.note?.trim()) {
1186
+ probe && (probe.promptReads = (probe.promptReads || 0) + 1);
1187
+ const prompt = readPromptFile(id);
1188
+ parts.promptPreview = prompt ? oneLinePreview(prompt) : null;
1189
+ }
1190
+ rows.push({
1191
+ id: rec.session,
1192
+ title: deriveTitle(parts),
1193
+ label: deriveLabel(parts),
1194
+ closedAt: rec.closedAt,
1195
+ node: rec.node,
1196
+ });
1197
+ }
1198
+ return rows;
1199
+ }
1200
1200
  // Preserve rows through a transient record-read failure; prune after the store entry disappears.
1201
1201
  const lastKnownSession = new Map();
1202
1202
  // A BOARD row carries the launch ask only as its one-line preview. The full text is served by the
@@ -1222,9 +1222,23 @@ export async function listSessions(includeArchived = false) {
1222
1222
  }
1223
1223
  catch { /* guardSession below preserves the last-known row for a transient read failure */ }
1224
1224
  }
1225
- // Only archived adapter records need the resident-ID join. If there are none, this read path performs zero
1226
- // control-plane probes; resources still owns the full turn/read probe for its detailed report.
1227
- const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && entry.liveness === null && rec && rec.governed && rec.archived && rec.harnessSessionId
1225
+ const canonical = configuredSessionApplicationIfCutover();
1226
+ const canonicalStates = new Map();
1227
+ if (canonical) {
1228
+ for (const [id, snapshot] of snapshots) {
1229
+ if (!snapshot.rec?.governed)
1230
+ continue;
1231
+ const state = canonical.readState(id);
1232
+ if (!state)
1233
+ throw new ResourceConflict(`session ${id} has no canonical application state after JSON cutover`);
1234
+ canonicalStates.set(id, state);
1235
+ }
1236
+ }
1237
+ // Adapter-owned records have no pane witness. Join one project-wide resident-ID census to every exact
1238
+ // bound target, including live rows; otherwise a dead shared app-server could leave a stale headless record
1239
+ // online indefinitely. The descriptor probe remains one-per-generation, not one RPC per session.
1240
+ const censusRecords = [...snapshots.values()].flatMap(({ entry, rec }) => entry.kind === 'ok' && rec && rec.governed
1241
+ && rec.harnessSessionId && harnessById(rec.harness || defaultHarness.id).runtimeOwnership === 'adapter'
1228
1242
  ? [{ ...rec, harness: rec.harness || defaultHarness.id }]
1229
1243
  : []);
1230
1244
  const residentCensus = censusRecords.length ? await adapterLoadedReferenceState(censusRecords) : new Map();
@@ -1263,35 +1277,43 @@ export async function listSessions(includeArchived = false) {
1263
1277
  lastKnownSession.delete(id);
1264
1278
  return null;
1265
1279
  } // no record, or a self-launched (non-board) one
1280
+ const projectedRecord = canonicalRecordProjection(rec, canonicalStates.get(id));
1266
1281
  // A forced public liveness comes only from the shared record projection. Do not let live process/thread
1267
1282
  // evidence punch through it (including archive hazard repair).
1268
1283
  if (entry.kind === 'ok' && entry.liveness === 'offline') {
1269
- const pending = boardRow(toSession(rec, 'offline', 'offline'));
1284
+ const pending = boardRow(toSession(projectedRecord, 'offline', 'offline'));
1270
1285
  lastKnownSession.set(id, pending);
1271
1286
  return pending;
1272
1287
  }
1273
1288
  // the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
1274
1289
  // is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
1275
1290
  const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id));
1276
- const sessionHarness = harnessById(rec.harness || defaultHarness.id);
1277
- const resident = rec.harnessSessionId
1278
- ? residentCensus.get(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`)
1291
+ const sessionHarness = harnessById(projectedRecord.harness || defaultHarness.id);
1292
+ const resident = projectedRecord.harnessSessionId
1293
+ ? residentCensus.get(`${projectedRecord.harness || defaultHarness.id}:${projectedRecord.harnessSessionId}`)
1279
1294
  : undefined;
1280
- const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!rec.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length;
1281
- const physical = rec.archived
1295
+ const residentRequired = sessionHarness.runtimeOwnership === 'adapter' && !!projectedRecord.harnessSessionId && !!sessionHarness.sharedRuntimes?.(runtimeRoot()).length;
1296
+ const physical = projectedRecord.archived
1282
1297
  ? (sessionHarness.runtimeOwnership === 'adapter'
1283
1298
  ? (resident && !resident.healthy ? 'unknown' : resident?.loaded ? 'online' : snap.windows.has(id) ? 'online' : 'offline')
1284
- : liveness({ ...rec, archived: false, stopped: false }, snap))
1299
+ : liveness({ ...projectedRecord, archived: false, stopped: false }, snap))
1285
1300
  : null;
1286
1301
  // Only a physically-offline record projects as archived. A legacy archived+live/unknown record is exposed
1287
1302
  // as ordinary working-set state with its real liveness/status and one backend-owned hazard marker. A
1288
1303
  // missing durable cold proof is also legacy: leaf liveness alone cannot prove a Codex loaded thread was
1289
1304
  // unloaded, so it remains visible until an explicit archive repair.
1290
- const cleanCold = rec.archived && !changedDuringCensus.has(id) && hasValidColdProof(rec) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
1291
- const projected = rec.archived && !cleanCold ? { ...rec, archived: false, stopped: false } : rec;
1292
- const projectedLv = projected === rec ? liveness(rec, snap) : physical;
1293
- const s = boardRow(toSession(projected, reconcile(projected, snap), projectedLv, activity));
1294
- if (projected !== rec)
1305
+ const cleanCold = projectedRecord.archived && !changedDuringCensus.has(id) && hasValidColdProof(projectedRecord) && physical === 'offline' && (!residentRequired || resident?.healthy === true);
1306
+ const projected = projectedRecord.archived && !cleanCold ? { ...projectedRecord, archived: false, stopped: false } : projectedRecord;
1307
+ const projectedLv = projected === projectedRecord
1308
+ ? sessionHarness.runtimeOwnership === 'adapter'
1309
+ ? adapterResidentLiveness(projectedRecord, resident)
1310
+ : liveness(projectedRecord, snap)
1311
+ : physical;
1312
+ const s = boardRow(toSession(projected, reconcile(projected, snap, projectedLv), projectedLv, activity));
1313
+ // Canonical projection deliberately creates a fresh object for every governed row. That identity change is not
1314
+ // an archive failure: a hazard belongs only to a record that was actually archived and then had its cold proof
1315
+ // rejected. Otherwise every live row would inherit the missing-cold-witness message after cutover.
1316
+ if (projectedRecord.archived && !cleanCold)
1295
1317
  s.archiveHazard = changedDuringCensus.has(id)
1296
1318
  ? 'archived runtime hazard: record changed while adapter residency was being reconciled; retry exact archive'
1297
1319
  : hasValidColdProof(rec)
@@ -1308,7 +1330,7 @@ export async function listSessions(includeArchived = false) {
1308
1330
  lastKnownSession.set(id, s);
1309
1331
  return s;
1310
1332
  }, () => {
1311
- // DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
1333
+ // DEGRADED: the record dir still exists but reading runtime.json failed transiently. NEVER drop a live
1312
1334
  // session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
1313
1335
  // show yet, and it reappears on the next build.)
1314
1336
  return lastKnownSession.get(id) ?? null;
@@ -1552,6 +1574,10 @@ export function launchPreflight(rec) {
1552
1574
  }
1553
1575
  // @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
1554
1576
  // invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
1577
+ // 后端把这条命令输入交互式 shell,脚本路径必须作为一个 shell 参数传递。
1578
+ export function launchShellCommand(file) {
1579
+ return `bash ${shQuote(file)}`;
1580
+ }
1555
1581
  export function launchScript(id, tail, harness = HARNESS, cmd) {
1556
1582
  const file = join(storeDir(id), 'launch.sh');
1557
1583
  // NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
@@ -1559,7 +1585,7 @@ export function launchScript(id, tail, harness = HARNESS, cmd) {
1559
1585
  // agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
1560
1586
  // `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
1561
1587
  // ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
1562
- const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`;
1588
+ const invocation = `${rvEnv(id, harness, readRecord(id)?.runtimeStartToken)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`;
1563
1589
  // @@@ birth registration - record the AGENT's real pid BEFORE exec, the anchor of the 100ms hot death tier
1564
1590
  // ([[state]]). Each attempt runs `sh -c '<pid-write>; exec env <invocation>'`: the sh writes its own `$$` to
1565
1591
  // agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
@@ -1613,7 +1639,7 @@ export function launchScript(id, tail, harness = HARNESS, cmd) {
1613
1639
  // -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
1614
1640
  // can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
1615
1641
  // bounded retry stands.
1616
- ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
1642
+ ` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-.}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
1617
1643
  ` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
1618
1644
  ` exit $__spex_rc`,
1619
1645
  ` fi`,
@@ -1634,7 +1660,8 @@ async function launch(id, path, tail, harness = HARNESS, cmd) {
1634
1660
  if (harness.ownsRendezvous)
1635
1661
  stampRvSock(id);
1636
1662
  await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path]);
1637
- await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`]);
1663
+ const file = launchScript(id, tail, harness, cmd);
1664
+ await tmux(['send-keys', '-t', id, '-l', '--', launchShellCommand(file)]);
1638
1665
  await tmux(['send-keys', '-t', id, 'Enter']);
1639
1666
  launchedAt.set(id, Date.now()); // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
1640
1667
  }
@@ -1642,10 +1669,9 @@ const OCCUPIES_SLOT = new Set(['working', 'parked', 'starting']); // starting's
1642
1669
  function isOccupying(s, snap) {
1643
1670
  if (!OCCUPIES_SLOT.has(s.status))
1644
1671
  return false; // waiting-on-human / proposed / queued / dead → free
1645
- const rec = readRecord(s.id);
1646
- if (!rec)
1647
- return false;
1648
- return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online';
1672
+ // `listSessions` already joined the adapter resident census and projected the resulting liveness. Re-reading
1673
+ // the harness here would resurrect the old record-backed codex-headless shortcut and disagree with the row.
1674
+ return s.liveness === 'online';
1649
1675
  }
1650
1676
  // sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
1651
1677
  // `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
@@ -1675,23 +1701,167 @@ async function withSessionTransition(id, body) {
1675
1701
  }
1676
1702
  }
1677
1703
  let draining = false; // re-entrancy guard: only one drain pass runs at a time (no double-launch)
1678
- function noteQueuedLaunchFailureUnlocked(id, error) {
1704
+ // A native receipt is bound before the readiness fence validates it. Suppress only that immediate wake so
1705
+ // queued prompts cannot drain during the candidate window; the successful publication path drains normally.
1706
+ const readinessWakeSuppressed = new Set();
1707
+ function noteQueuedLaunchFailureUnlocked(id, error, terminal = true, label, live = false) {
1679
1708
  const reason = error instanceof Error ? error.message : String(error);
1680
- const note = `queued launch readiness failed: ${reason}`;
1709
+ const note = `${label ?? (terminal ? 'queued launch readiness failed' : 'launch readiness warning')}: ${reason}`;
1681
1710
  console.error(`spex: session ${id}: ${note}`);
1682
1711
  const rec = readRecord(id);
1683
- if (rec && rec.note !== note)
1684
- writeRecord({ ...rec, note });
1712
+ if (rec && !retirementReason(rec) && (rec.note !== note
1713
+ || (terminal && (rec.status !== 'error' || !rec.stopped || rec.launchReadinessStartedAt != null))
1714
+ || (!terminal && live && (rec.status === 'error' || rec.stopped)))) {
1715
+ // Readiness failure is terminal for this launch attempt. Keep the exact reason on the record,
1716
+ // publish an offline/error transition, and clear every durable/in-memory ownership marker so close
1717
+ // and a later explicit resume have an honest starting point.
1718
+ if (terminal) {
1719
+ publishCanonicalLifecycle(rec, 'error', null, note);
1720
+ writeRecord({ ...rec, status: 'error', proposal: null, stopped: true, note, launchOwner: null, launchReadinessStartedAt: null });
1721
+ }
1722
+ else {
1723
+ const status = live && (rec.status === 'error' || rec.stopped) ? 'active' : rec.status;
1724
+ const stopped = live ? false : rec.stopped;
1725
+ const restored = { ...rec, status, stopped, note, launchOwner: null, launchReadinessStartedAt: null };
1726
+ // A live post-receipt timeout is a diagnostic, not a new parent-watch transition. If an older failed
1727
+ // attempt already published `error`, however, the canonical row must be repaired to the live status or
1728
+ // the JSON write below would leave the sole lifecycle authority disagreeing with the runtime witness.
1729
+ // Publish even when status is unchanged: the warning note is canonical too. Active recipients exclude
1730
+ // the parent, so this diagnostic cannot manufacture a parent-watch transition.
1731
+ publishCanonicalLifecycle(restored, status, restored.proposal, note);
1732
+ writeRecord(restored);
1733
+ }
1734
+ }
1685
1735
  }
1686
- function observeQueuedLaunchReadiness(id, harness) {
1687
- void waitForReady(id, harness)
1736
+ function clearReadinessResidueUnlocked(rec, clearDiagnostic) {
1737
+ const application = configuredSessionApplicationIfCutover();
1738
+ const next = {
1739
+ ...rec,
1740
+ status: 'active',
1741
+ stopped: false,
1742
+ note: clearDiagnostic ? null : rec.note,
1743
+ launchReadinessStartedAt: null,
1744
+ };
1745
+ if (application?.readState(rec.session) && (clearDiagnostic || rec.status !== 'active' || rec.stopped)) {
1746
+ application.transitionSession(rec.session, {
1747
+ status: 'active',
1748
+ proposal: rec.proposal,
1749
+ note: next.note,
1750
+ parentSessionId: rec.parent,
1751
+ recipientSessionIds: [],
1752
+ });
1753
+ }
1754
+ writeRecord(next);
1755
+ }
1756
+ export function canonicalWatchRecipients(application, sessionId, status) {
1757
+ const recipients = new Set();
1758
+ for (const edge of application.topology.parents(sessionId)) {
1759
+ // The canonical topology stores the structural parent edge as the durable parent-watch source. Older
1760
+ // migrated rows may also have an explicit watch:parent edge; both represent the same policy source.
1761
+ if (edge.relationType !== 'parent' && !edge.relationType.startsWith('watch'))
1762
+ continue;
1763
+ if (status === 'active' && (edge.relationType === 'parent' || edge.relationType === 'watch:parent'))
1764
+ continue;
1765
+ recipients.add(edge.fromSessionId);
1766
+ }
1767
+ return [...recipients];
1768
+ }
1769
+ export function sessionHasPendingDelivery(id, application = configuredSessionApplicationIfCutover() ?? null) {
1770
+ if (!application)
1771
+ throw new ResourceConflict(`session application is unavailable for ${id}`);
1772
+ const runtime = application.resolveRuntime?.(id, 'spex-governed');
1773
+ if (runtime === null)
1774
+ return false;
1775
+ try {
1776
+ return application.readPendingMessages(id).length > 0;
1777
+ }
1778
+ catch (error) {
1779
+ // A legacy record can outlive its migrated protocol address. It has no canonical queue to drain;
1780
+ // treating that address as owed makes the supervisor retry the same impossible lookup forever.
1781
+ if (error?.code === 'PROTOCOL_SESSION_UNKNOWN'
1782
+ || /unknown protocol address/i.test(error instanceof Error ? error.message : String(error)))
1783
+ return false;
1784
+ throw error;
1785
+ }
1786
+ }
1787
+ export function canonicalRecordProjection(rec, canonical) {
1788
+ // The application row is the only lifecycle fact after cutover. A JSON status is historical envelope data,
1789
+ // so it must not win merely because it says waiting/error/archived while the canonical row says otherwise.
1790
+ if (!canonical) {
1791
+ return rec;
1792
+ }
1793
+ return {
1794
+ ...rec,
1795
+ status: canonical.status,
1796
+ proposal: canonical.proposal,
1797
+ note: canonical.note,
1798
+ parent: canonical.parentSessionId,
1799
+ };
1800
+ }
1801
+ function publishCanonicalLifecycle(rec, status, proposal, note) {
1802
+ const application = configuredSessionApplicationIfCutover();
1803
+ if (!application)
1804
+ return;
1805
+ if (!application.readState(rec.session)) {
1806
+ application.createSession({ sessionId: rec.session, status, proposal, note, parentSessionId: rec.parent });
1807
+ if (rec.parent)
1808
+ application.attachWatcher(rec.parent, rec.session, 'watch:parent');
1809
+ return;
1810
+ }
1811
+ application.transitionSession(rec.session, {
1812
+ status,
1813
+ proposal,
1814
+ note,
1815
+ parentSessionId: rec.parent,
1816
+ recipientSessionIds: canonicalWatchRecipients(application, rec.session, status),
1817
+ });
1818
+ }
1819
+ async function launchReadinessWitnessAlive(id, harness, current) {
1820
+ if (harness.runtimeOwnership === 'adapter') {
1821
+ const state = await adapterRuntimeLiveness({ ...current, stopped: false, archived: false });
1822
+ return state === 'online';
1823
+ }
1824
+ if (agentAlive(id) === true)
1825
+ return true;
1826
+ try {
1827
+ const snap = await liveSnapshot(id);
1828
+ return harness.liveness(current, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online';
1829
+ }
1830
+ catch {
1831
+ return false;
1832
+ }
1833
+ }
1834
+ export function adapterResidentLiveness(rec, resident) {
1835
+ if (rec.stopped || rec.archived)
1836
+ return 'offline';
1837
+ if (!rec.harnessSessionId)
1838
+ return 'offline';
1839
+ if (!resident)
1840
+ return 'unknown';
1841
+ if (!resident.healthy)
1842
+ return 'unknown';
1843
+ return resident.loaded ? 'online' : 'offline';
1844
+ }
1845
+ async function adapterRuntimeLiveness(rec) {
1846
+ if (rec.stopped || rec.archived)
1847
+ return 'offline';
1848
+ const harness = harnessById(rec.harness || defaultHarness.id);
1849
+ if (harness.runtimeOwnership !== 'adapter')
1850
+ return liveness(rec, await liveSnapshot());
1851
+ if (!rec.harnessSessionId)
1852
+ return 'offline';
1853
+ const states = await adapterLoadedReferenceState([{ ...rec, harness: harness.id }], runtimeRoot());
1854
+ return adapterResidentLiveness(rec, states.get(`${harness.id}:${rec.harnessSessionId}`));
1855
+ }
1856
+ function observeQueuedLaunchReadiness(id, harness, timeoutMs = SOCKET_READY_TIMEOUT_MS) {
1857
+ void waitForReady(id, harness, undefined, timeoutMs)
1688
1858
  .then(async (readiness) => {
1689
1859
  if (!readiness) {
1690
1860
  const committed = !!readRecord(id)?.harnessSessionId;
1691
1861
  throw new ResourceConflict(harness.launchPayloadProof
1692
1862
  ? committed
1693
- ? 'post-proof adapter liveness did not become ready before launch readiness timed out'
1694
- : 'native identity and first-turn rollout proof did not arrive before launch readiness timed out'
1863
+ ? 'post-receipt adapter liveness did not become ready before launch readiness timed out'
1864
+ : 'native identity and first-turn rollout receipt did not arrive before launch readiness timed out'
1695
1865
  : 'adapter liveness did not become ready before launch readiness timed out');
1696
1866
  }
1697
1867
  let readyToPublish = false;
@@ -1708,23 +1878,35 @@ function observeQueuedLaunchReadiness(id, harness) {
1708
1878
  const current = readRecord(id);
1709
1879
  if (!current)
1710
1880
  return;
1711
- if (current.status === 'queued')
1712
- writeRecord({ ...current, status: 'active', proposal: null, note: null, launchOwner: null });
1881
+ if (current.status === 'queued') {
1882
+ publishCanonicalLifecycle(current, 'active', null, null);
1883
+ writeRecord({ ...current, status: 'active', proposal: null, note: null, launchOwner: null, launchReadinessStartedAt: null });
1884
+ }
1885
+ else if (current.launchReadinessStartedAt != null)
1886
+ writeRecord({ ...current, launchReadinessStartedAt: null });
1713
1887
  readyToPublish = true;
1714
1888
  });
1715
1889
  if (!readyToPublish)
1716
1890
  return;
1717
- await deliverPendingWatchSnapshots(id);
1718
1891
  await drainSession(id);
1719
1892
  })
1720
1893
  .catch(async (error) => {
1894
+ const reason = error instanceof Error ? error.message : String(error);
1895
+ const timedOut = /timed out|did not become ready/i.test(reason);
1896
+ let live = false;
1897
+ let terminal = timedOut;
1721
1898
  try {
1722
- await withRecordLock(id, async () => noteQueuedLaunchFailureUnlocked(id, error));
1899
+ await withRecordLock(id, async () => {
1900
+ const current = readRecord(id);
1901
+ if (timedOut && current)
1902
+ live = await launchReadinessWitnessAlive(id, harness, current);
1903
+ terminal = timedOut && !live;
1904
+ noteQueuedLaunchFailureUnlocked(id, error, terminal, live ? 'launch readiness warning' : undefined, live);
1905
+ });
1723
1906
  }
1724
1907
  catch (recordError) {
1725
- console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${error instanceof Error ? error.message : String(error)}`);
1908
+ console.error(`spex: session ${id}: queued launch failure could not be recorded: ${recordError instanceof Error ? recordError.message : String(recordError)}; original failure: ${reason}`);
1726
1909
  }
1727
- await clearPendingWatchSnapshots(id);
1728
1910
  })
1729
1911
  .finally(() => launching.delete(id));
1730
1912
  }
@@ -1743,7 +1925,7 @@ async function startQueuedUnlocked(id) {
1743
1925
  if (!canDrainQueued(wt.rec))
1744
1926
  return 'retryable';
1745
1927
  const h = harnessById(wt.rec.harness || defaultHarness.id);
1746
- if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
1928
+ if (h.launchPayloadProof && hasReadableLaunchReceipt(id)) {
1747
1929
  launching.add(id);
1748
1930
  let readinessOwnsSlot = false;
1749
1931
  try {
@@ -1751,11 +1933,13 @@ async function startQueuedUnlocked(id) {
1751
1933
  consumeHarnessLaunchProofUnlocked(id);
1752
1934
  }
1753
1935
  catch (error) {
1754
- noteQueuedLaunchFailureUnlocked(id, error);
1936
+ noteQueuedLaunchFailureUnlocked(id, error, false);
1755
1937
  throw error;
1756
1938
  }
1757
1939
  const recovered = readRecord(id) || wt.rec;
1758
- writeRecord({ ...recovered, status: 'active', proposal: null, note: null, launchOwner: null });
1940
+ const readinessStartedAt = Date.now();
1941
+ publishCanonicalLifecycle(recovered, 'active', null, null);
1942
+ writeRecord({ ...recovered, status: 'active', proposal: null, note: null, launchOwner: null, launchReadinessStartedAt: readinessStartedAt });
1759
1943
  observeQueuedLaunchReadiness(id, h);
1760
1944
  readinessOwnsSlot = true;
1761
1945
  return 'started';
@@ -1788,11 +1972,17 @@ async function startQueuedUnlocked(id) {
1788
1972
  launching.add(id); // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
1789
1973
  let readinessOwnsSlot = false;
1790
1974
  try {
1975
+ const readinessStartedAt = Date.now();
1976
+ const stamped = readRecord(id) || wt.rec;
1977
+ writeRecord({ ...stamped, launchReadinessStartedAt: readinessStartedAt });
1791
1978
  try {
1792
1979
  const sq = shQuote(launchPrompt);
1793
1980
  await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec));
1794
1981
  }
1795
1982
  catch {
1983
+ const failedLaunch = readRecord(id);
1984
+ if (failedLaunch)
1985
+ writeRecord({ ...failedLaunch, launchReadinessStartedAt: null });
1796
1986
  return 'retryable'; // launch failed → stays `queued`, with its initial debt, for the next drain tick
1797
1987
  }
1798
1988
  // the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
@@ -1800,7 +1990,8 @@ async function startQueuedUnlocked(id) {
1800
1990
  // belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
1801
1991
  // headline precedence stands on.
1802
1992
  const launched = readRecord(id) || wt.rec;
1803
- writeRecord({ ...launched, status: 'active', proposal: null, note: null, launchOwner: null });
1993
+ publishCanonicalLifecycle(launched, 'active', null, null);
1994
+ writeRecord({ ...launched, status: 'active', proposal: null, note: null, launchOwner: null, launchReadinessStartedAt: readinessStartedAt });
1804
1995
  if (!h.launchPayloadProof)
1805
1996
  removeLaunchFile(id);
1806
1997
  // release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
@@ -1825,16 +2016,60 @@ async function drainQueueUnlocked() {
1825
2016
  const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()]);
1826
2017
  for (const session of sessions) {
1827
2018
  const rec = readRecord(session.id);
1828
- if (!rec || launching.has(session.id) || !readWatchEntries(session.id).some((entry) => entry.snapshotPending))
2019
+ if (!rec || launching.has(session.id))
1829
2020
  continue;
1830
- if (rec.status === 'queued')
2021
+ // Older timed-out rows predate the durable readiness timestamp. Reconcile their recorded failure
2022
+ // before any queue/watch work so a backend restart cannot resurrect the old active/limbo projection.
2023
+ if (rec.status !== 'queued' && /^queued launch readiness failed:/.test(rec.note || '')) {
2024
+ const priorReason = (rec.note || '').replace(/^queued launch readiness failed:\s*/, '') || 'launch readiness timed out';
2025
+ const harness = harnessById(rec.harness || defaultHarness.id);
2026
+ const live = await launchReadinessWitnessAlive(session.id, harness, rec);
2027
+ if (live) {
2028
+ await withRecordLock(session.id, async () => {
2029
+ const current = readRecord(session.id);
2030
+ if (current && !current.archived && !current.stopped)
2031
+ clearReadinessResidueUnlocked(current, true);
2032
+ });
2033
+ continue;
2034
+ }
2035
+ await withRecordLock(session.id, async () => noteQueuedLaunchFailureUnlocked(session.id, priorReason, !live, live ? 'launch readiness warning' : undefined, live));
1831
2036
  continue;
1832
- if (rec.status === 'active' && !rec.stopped && !rec.archived) {
2037
+ }
2038
+ // A pre-fix active row may still carry the authoritative launch artifact without a timestamp. Its
2039
+ // mtime is the only durable age witness available; seed the new field so the same bounded recovery
2040
+ // rule applies on this and later restarts.
2041
+ if (rec.status === 'active' && !rec.stopped && existsSync(sessionArtifactPath(session.id, 'launch')) && rec.launchReadinessStartedAt == null) {
2042
+ const harness = harnessById(rec.harness || defaultHarness.id);
2043
+ const live = await launchReadinessWitnessAlive(session.id, harness, rec);
2044
+ if (!live) {
2045
+ let startedAt = Date.now();
2046
+ try {
2047
+ startedAt = statSync(sessionArtifactPath(session.id, 'launch')).mtimeMs;
2048
+ }
2049
+ catch { /* race: observer below will fail loud */ }
2050
+ writeRecord({ ...rec, launchReadinessStartedAt: startedAt });
2051
+ }
2052
+ }
2053
+ const refreshed = readRecord(session.id) || rec;
2054
+ if (refreshed.launchReadinessStartedAt && !refreshed.stopped && !refreshed.archived) {
2055
+ const harness = harnessById(refreshed.harness || defaultHarness.id);
2056
+ const live = await launchReadinessWitnessAlive(session.id, harness, refreshed);
2057
+ if (live) {
2058
+ await withRecordLock(session.id, async () => {
2059
+ const current = readRecord(session.id);
2060
+ if (current && !current.archived && !current.stopped) {
2061
+ clearReadinessResidueUnlocked(current, /^launch readiness warning:/.test(current.note || ''));
2062
+ }
2063
+ });
2064
+ continue;
2065
+ }
1833
2066
  launching.add(session.id);
1834
- observeQueuedLaunchReadiness(session.id, harnessById(rec.harness || defaultHarness.id));
2067
+ const remaining = Math.max(0, SOCKET_READY_TIMEOUT_MS - (Date.now() - refreshed.launchReadinessStartedAt));
2068
+ observeQueuedLaunchReadiness(session.id, harness, remaining);
1835
2069
  continue;
1836
2070
  }
1837
- await deliverPendingWatchSnapshots(session.id, false);
2071
+ if (rec.status === 'queued')
2072
+ continue;
1838
2073
  }
1839
2074
  // if the liveness probe FAILED (tmux timing out — the overload condition), occupancy is UNKNOWABLE: every
1840
2075
  // session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
@@ -1844,13 +2079,6 @@ async function drainQueueUnlocked() {
1844
2079
  break;
1845
2080
  const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0);
1846
2081
  if (occupied >= cap) {
1847
- const authority = backendLaunchAuthority();
1848
- await Promise.all(sessions.filter((session) => {
1849
- if (session.status !== 'queued')
1850
- return false;
1851
- const rec = readRecord(session.id);
1852
- return !!rec && canDrainQueued(rec, authority);
1853
- }).map((session) => deliverPendingWatchSnapshots(session.id)));
1854
2082
  break;
1855
2083
  }
1856
2084
  const authority = backendLaunchAuthority();
@@ -1864,8 +2092,6 @@ async function drainQueueUnlocked() {
1864
2092
  break;
1865
2093
  const started = await startQueued(next.id);
1866
2094
  if (started !== 'started') {
1867
- if (started === 'blocked')
1868
- await clearPendingWatchSnapshots(next.id);
1869
2095
  break; // launch failed → stop this pass; a later tick retries
1870
2096
  }
1871
2097
  }
@@ -1880,6 +2106,18 @@ const requestQueueDrain = () => {
1880
2106
  console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`);
1881
2107
  });
1882
2108
  };
2109
+ // Canonical state commits already own the durable recipient queue. This is only the post-commit wake that hands
2110
+ // each queued recipient to its existing runtime; a failed or absent runtime leaves the message pending for retry.
2111
+ setSessionApplicationCommitWake((recipients) => {
2112
+ const wakeRecipients = recipients.filter(recipient => !readinessWakeSuppressed.has(recipient));
2113
+ queueMicrotask(() => {
2114
+ for (const recipient of wakeRecipients) {
2115
+ void drainSession(recipient).catch((error) => {
2116
+ console.error(`spex: canonical delivery wake failed for ${recipient}: ${error instanceof Error ? error.message : String(error)}`);
2117
+ });
2118
+ }
2119
+ });
2120
+ });
1883
2121
  let supervisingQueue = false;
1884
2122
  export function superviseQueue(intervalMs = 3000) {
1885
2123
  if (supervisingQueue)
@@ -1907,22 +2145,28 @@ export function superviseDelivery(intervalMs = 2000) {
1907
2145
  supervisingDelivery = true;
1908
2146
  const tick = async () => {
1909
2147
  try {
2148
+ const application = configuredSessionApplicationIfCutover();
1910
2149
  for (const id of listSessionIds()) {
1911
- if (!owesDelivery(id))
2150
+ if (!sessionHasPendingDelivery(id, application))
1912
2151
  continue;
1913
2152
  try {
1914
2153
  await drainSession(id);
1915
2154
  }
1916
- catch { /* an adapter that refused stays owed; next tick retries */ }
2155
+ catch (error) {
2156
+ console.error(`spex: delivery retry failed for ${id}: ${error instanceof Error ? error.message : String(error)}`);
2157
+ }
1917
2158
  }
1918
2159
  }
1919
- catch { /* transient store read; next tick retries */ }
2160
+ catch (error) {
2161
+ console.error(`spex: delivery retry sweep failed: ${error instanceof Error ? error.message : String(error)}`);
2162
+ }
1920
2163
  setTimeout(tick, intervalMs).unref();
1921
2164
  };
1922
2165
  void tick();
1923
2166
  }
1924
2167
  const turnFailureObservers = new Map();
1925
2168
  let supervisingTurnFailures = false;
2169
+ let startingTurnFailureObserver = false;
1926
2170
  const TURN_FAILURE_OBSERVER_STABLE_MS = 5000;
1927
2171
  export function turnFailureNote(harness, failure) {
1928
2172
  const message = failure.message.replace(/\s+/g, ' ').trim().slice(0, 500) || 'turn failed';
@@ -1953,7 +2197,10 @@ export function reconcileTurnFailureObservers() {
1953
2197
  catch {
1954
2198
  continue;
1955
2199
  }
1956
- if (!rec?.governed || rec.stopped || rec.archived || !rec.harnessSessionId)
2200
+ // Native turn failure observation is for an executing turn, not a durable roster census. Asking, awaiting,
2201
+ // and parked records have no turn to observe; subscribing them creates one expensive app-server resume per
2202
+ // idle record and lets stale observers accumulate after a backend restart.
2203
+ if (!rec?.governed || rec.stopped || rec.archived || rec.status !== 'active' || !rec.harnessSessionId)
1957
2204
  continue;
1958
2205
  const harness = harnessById(rec.harness || defaultHarness.id);
1959
2206
  if (!harness.observeTurnFailures)
@@ -1977,11 +2224,16 @@ export function reconcileTurnFailureObservers() {
1977
2224
  }
1978
2225
  continue;
1979
2226
  }
2227
+ // Codex thread/resume is an expensive native subscription under load. Admit one observer at a time so a
2228
+ // backend restart cannot fan out N concurrent history reconciliations and exhaust CPU/RSS before any can settle.
2229
+ if (startingTurnFailureObserver)
2230
+ continue;
1980
2231
  if (state && now < state.retryAt)
1981
2232
  continue;
1982
2233
  state ??= { fingerprint: target.fingerprint, subscription: null, startedAt: 0, failures: 0, retryAt: 0, lastReason: null };
1983
2234
  state.startedAt = now;
1984
2235
  turnFailureObservers.set(id, state);
2236
+ startingTurnFailureObserver = true;
1985
2237
  try {
1986
2238
  const subscription = target.harness.observeTurnFailures({
1987
2239
  session: id,
@@ -2000,7 +2252,12 @@ export function reconcileTurnFailureObservers() {
2000
2252
  }
2001
2253
  });
2002
2254
  state.subscription = subscription;
2255
+ if (subscription.ready)
2256
+ void subscription.ready.then(() => { startingTurnFailureObserver = false; }, () => { startingTurnFailureObserver = false; });
2257
+ else
2258
+ startingTurnFailureObserver = false;
2003
2259
  void subscription.closed.then((reason) => {
2260
+ startingTurnFailureObserver = false;
2004
2261
  if (turnFailureObservers.get(id) !== state)
2005
2262
  return;
2006
2263
  if (reason)
@@ -2010,6 +2267,7 @@ export function reconcileTurnFailureObservers() {
2010
2267
  });
2011
2268
  }
2012
2269
  catch (error) {
2270
+ startingTurnFailureObserver = false;
2013
2271
  deferTurnFailureObserver(id, target.harness.id, state, error instanceof Error ? error.message : String(error));
2014
2272
  }
2015
2273
  }
@@ -2150,6 +2408,13 @@ export async function sessionCreateRequest(body, options = {}) {
2150
2408
  if (input.base !== undefined && typeof input.base !== 'string')
2151
2409
  return { status: 400, error: 'session-create base must be a string' };
2152
2410
  const base = typeof input.base === 'string' && input.base.trim() ? input.base.trim() : null;
2411
+ const cutoverState = sessionApplicationCutoverState();
2412
+ if (cutoverState === 'fenced')
2413
+ return { status: 409, error: 'legacy JSON session store is fenced for one-time migration', code: 'session_create_failed', phase: 'request' };
2414
+ if (cutoverState === 'migration-required')
2415
+ return { status: 409, error: 'legacy JSON session store must be migrated before creating sessions', code: 'session_create_failed', phase: 'request' };
2416
+ if (cutoverState === 'ambiguous')
2417
+ return { status: 409, error: 'session database exists without a migration marker', code: 'session_create_failed', phase: 'request' };
2153
2418
  let key;
2154
2419
  try {
2155
2420
  key = normalizeCreateKey(options.requestKey);
@@ -2164,6 +2429,23 @@ export async function sessionCreateRequest(body, options = {}) {
2164
2429
  // immutable creation input because it publishes the record's existing display override. `base` joins them
2165
2430
  // for the same reason and with the same shape: absent, it must not perturb an existing receipt's bytes.
2166
2431
  const payloadHash = digest(JSON.stringify({ prompt, parent, launcher: launcher ?? null, ...(name ? { name } : {}), ...(base ? { base } : {}) }));
2432
+ let freshStoreOwned = false;
2433
+ let freshStoreCommitted = false;
2434
+ try {
2435
+ const acquired = acquireFreshSessionApplicationForCreate();
2436
+ freshStoreOwned = acquired.owned;
2437
+ }
2438
+ catch (error) {
2439
+ return { status: 409, error: error instanceof Error ? error.message : String(error), code: 'session_create_failed', phase: 'request' };
2440
+ }
2441
+ try {
2442
+ assertLegacyJsonWritesAllowed();
2443
+ }
2444
+ catch (error) {
2445
+ releaseFreshSessionApplicationForCreate(freshStoreOwned, false);
2446
+ const message = error instanceof Error ? error.message : String(error);
2447
+ return { status: 409, error: message, code: 'session_create_failed', phase: 'request' };
2448
+ }
2167
2449
  const controller = new AbortController();
2168
2450
  const cancel = () => controller.abort(new SessionCreateError('session_create_cancelled', 'request', 'session creation caller disconnected', 408));
2169
2451
  if (options.signal?.aborted)
@@ -2176,6 +2458,8 @@ export async function sessionCreateRequest(body, options = {}) {
2176
2458
  try {
2177
2459
  try {
2178
2460
  const session = await prepareSession(prompt, parent, launcher, name, { id, requestDigest, payloadHash, base, signal: controller.signal });
2461
+ await options.onPublished?.(session);
2462
+ freshStoreCommitted = true;
2179
2463
  traceSessionCreate(id, requestDigest, 'request', 'finish');
2180
2464
  return { status: 201, session };
2181
2465
  }
@@ -2189,6 +2473,7 @@ export async function sessionCreateRequest(body, options = {}) {
2189
2473
  }
2190
2474
  }
2191
2475
  finally {
2476
+ releaseFreshSessionApplicationForCreate(freshStoreOwned, freshStoreCommitted);
2192
2477
  clearTimeout(timer);
2193
2478
  options.signal?.removeEventListener('abort', cancel);
2194
2479
  }
@@ -2244,7 +2529,7 @@ export async function createSession(prompt, launcher, name, base) {
2244
2529
  const refused = await probeSessionCreateAuthority(target);
2245
2530
  if (refused) {
2246
2531
  console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)');
2247
- const fallback = await sessionCreateRequest(body, { requestKey });
2532
+ const fallback = await sessionCreateRequest(body, { requestKey, onPublished: projectCreatedSession });
2248
2533
  if (fallback.status === 201)
2249
2534
  return fallback.session;
2250
2535
  const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`);
@@ -2284,6 +2569,27 @@ export async function createSession(prompt, launcher, name, base) {
2284
2569
  }
2285
2570
  return await res.json();
2286
2571
  }
2572
+ export function projectCreatedSession(session) {
2573
+ const application = initializeFreshSessionApplication();
2574
+ try {
2575
+ application.createSession({
2576
+ sessionId: session.id,
2577
+ status: session.lifecycle,
2578
+ parentSessionId: session.parent,
2579
+ proposal: session.proposal,
2580
+ note: session.note,
2581
+ });
2582
+ if (session.parent)
2583
+ application.attachWatcher(session.parent, session.id, 'watch:parent');
2584
+ }
2585
+ catch (error) {
2586
+ const state = application.readState(session.id);
2587
+ const sameProjection = state?.status === session.lifecycle
2588
+ && state.parentSessionId === (session.parent ?? null);
2589
+ if (!sameProjection)
2590
+ throw error;
2591
+ }
2592
+ }
2287
2593
  export function spawnerClause(p) {
2288
2594
  if (!p?.worktreePath)
2289
2595
  return '';
@@ -2545,7 +2851,7 @@ function existingCreateReceipt(rec) {
2545
2851
  const h = harnessById(rec.harness || defaultHarness.id);
2546
2852
  if (rec.status === 'queued')
2547
2853
  return toSession(rec, 'queued', 'offline');
2548
- const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? PROPOSAL_STATUS[rec.proposal ?? 'nothing'] : rec.status;
2854
+ const status = rec.status === 'active' ? 'working' : rec.status === 'awaiting' ? displayStatusForProposal(rec.proposal) : rec.status;
2549
2855
  return toSession(rec, status, rec.stopped ? 'offline' : h.headless ? 'online' : 'starting');
2550
2856
  }
2551
2857
  async function proveSessionCandidate(path, branch, signal) {
@@ -2695,13 +3001,21 @@ async function prepareSession(prompt, parent, launcher, name, context) {
2695
3001
  traceSessionCreate(id, requestDigest, phase, 'start', 'seed-worktree-host-state');
2696
3002
  seedWorktreeHostState(root, path);
2697
3003
  traceSessionCreate(id, requestDigest, phase, 'finish', 'seed-worktree-host-state');
3004
+ // The branch ref right after `worktree add` IS the fork point. Record it: it is the only thing that
3005
+ // later separates "this branch never authored a commit" from "its commits landed in the base", and
3006
+ // git ancestry alone cannot tell those apart. A read that fails leaves it null — the diff reader
3007
+ // recovers the same commit from the branch's creation reflog entry.
3008
+ const forkResolved = await withGitAbortSignal(signal, () => gitTry(['-C', root, 'rev-parse', '--verify', `refs/heads/${branch}^{commit}`]));
3009
+ const forkCommit = forkResolved.ok && isGitObjectId(root, forkResolved.stdout.trim()) ? forkResolved.stdout.trim() : null;
2698
3010
  let rec = {
2699
3011
  session: id, governed: true, worktreePath: path, branch,
2700
3012
  node: ref || null, title, name, parent: parent && parent !== id ? parent : null,
2701
3013
  status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
2702
- harness: h.id, harnessSessionId: null, stopped: false, archived: false, coldProof: null, adapterRecovery: null, launcher: chosen.name,
3014
+ harness: h.id, harnessSessionId: null, runtimeStartToken: randomUUID(), stopped: false, archived: false, closedAt: null, coldProof: null, adapterRecovery: null, launcher: chosen.name,
2703
3015
  launchCmd: pinned, launchOwner: backendLaunchAuthority(), createRequestId: requestDigest, createPayloadHash: payloadHash,
3016
+ diffComments: [],
2704
3017
  ...(base ? { base } : {}),
3018
+ ...(forkCommit ? { forkCommit } : {}),
2705
3019
  };
2706
3020
  owned.store = true;
2707
3021
  const dir = storeDir(id);
@@ -2720,13 +3034,8 @@ async function prepareSession(prompt, parent, launcher, name, context) {
2720
3034
  if (gitMismatch)
2721
3035
  throw new SessionCreateError('session_create_failed', phase, `refusing session publication: ${gitMismatch}`, 500);
2722
3036
  throwIfCreateAborted(signal, phase);
3037
+ publishCanonicalLifecycle(rec, rec.status, rec.proposal, rec.note);
2723
3038
  writeRecord(rec);
2724
- if (rec.parent && readRecord(rec.parent)?.governed) {
2725
- const watchers = readWatchEntries(id);
2726
- const next = addWatchSource(watchers, rec.parent, 'parent', true);
2727
- if (next.added)
2728
- writeWatchEntries(id, next.entries);
2729
- }
2730
3039
  published = true;
2731
3040
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(rec, root);
2732
3041
  if (receiptFailure)
@@ -2819,11 +3128,26 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
2819
3128
  const deadline = Date.now() + timeoutMs;
2820
3129
  if (harness.launchPayloadProof && !current()?.harnessSessionId) {
2821
3130
  for (;;) {
2822
- if (existsSync(sessionArtifactPath(id, 'launch.proof'))) {
2823
- if (recordLockHeld)
2824
- consumeHarnessLaunchProofUnlocked(id);
3131
+ if (hasReadableLaunchReceipt(id)) {
3132
+ if (recordLockHeld) {
3133
+ readinessWakeSuppressed.add(id);
3134
+ try {
3135
+ consumeHarnessLaunchProofUnlocked(id);
3136
+ }
3137
+ finally {
3138
+ readinessWakeSuppressed.delete(id);
3139
+ }
3140
+ }
2825
3141
  else
2826
- await withRecordLock(id, async () => consumeHarnessLaunchProofUnlocked(id));
3142
+ await withRecordLock(id, async () => {
3143
+ readinessWakeSuppressed.add(id);
3144
+ try {
3145
+ consumeHarnessLaunchProofUnlocked(id);
3146
+ }
3147
+ finally {
3148
+ readinessWakeSuppressed.delete(id);
3149
+ }
3150
+ });
2827
3151
  break;
2828
3152
  }
2829
3153
  if (Date.now() >= deadline)
@@ -2851,7 +3175,57 @@ async function waitForReady(id, harness, pending, timeoutMs = SOCKET_READY_TIMEO
2851
3175
  await new Promise((r) => setTimeout(r, SOCKET_POLL_MS));
2852
3176
  }
2853
3177
  }
2854
- const restingLifecycle = (status) => status === 'active' || status === 'queued' ? 'idle' : status;
3178
+ // An explicit successful resume is a new runtime attempt. A prior terminal launch/turn error must not
3179
+ // survive that handoff as current lifecycle truth; waiting declarations remain waiting declarations.
3180
+ const restingLifecycle = (status) => status === 'active' || status === 'queued' || status === 'error' ? 'idle' : status;
3181
+ const resumeNote = (status, note) => status === 'error' ? null : note;
3182
+ const archiveRef = (id) => `refs/spex-archive/${id}`;
3183
+ function archiveWorktreeState(id, path) {
3184
+ const root = mainRoot();
3185
+ try {
3186
+ const parent = git(['-C', path, 'rev-parse', 'HEAD']).trim();
3187
+ git(['-C', path, 'add', '-A']);
3188
+ const tree = git(['-C', path, 'write-tree']).trim();
3189
+ const commit = git(['-C', path, '-c', 'user.name=SpexCode', '-c', 'user.email=spexcode@localhost', 'commit-tree', tree, '-p', parent, '-m', `spex close archive ${id}`]).trim();
3190
+ if (!/^[0-9a-f]{40,64}$/.test(commit))
3191
+ throw new Error('archive commit was malformed');
3192
+ git(['-C', root, 'update-ref', archiveRef(id), commit]);
3193
+ const stored = git(['-C', root, 'rev-parse', '--verify', `${archiveRef(id)}^{commit}`]).trim();
3194
+ if (stored !== commit)
3195
+ throw new Error('archive ref publication was not verified');
3196
+ return commit;
3197
+ }
3198
+ catch (error) {
3199
+ const detail = error instanceof Error ? error.message.split('\n')[0] : String(error);
3200
+ throw new ResourceConflict(`refusing to close ${id}: could not publish ${archiveRef(id)}${detail ? ` - ${detail}` : ''}`);
3201
+ }
3202
+ }
3203
+ async function restoreArchivedWorktree(id, rec) {
3204
+ if (existsSync(rec.worktreePath))
3205
+ return;
3206
+ if (!rec.branch)
3207
+ throw new ResourceConflict(`session ${id} has no branch to restore its archived worktree`);
3208
+ const ref = archiveRef(id);
3209
+ const archive = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${ref}^{commit}`]);
3210
+ const start = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `refs/heads/${rec.branch}^{commit}`]);
3211
+ if (!start.ok)
3212
+ throw new ResourceConflict(`session ${id} branch ${rec.branch} is missing`);
3213
+ await gitTry(['-C', mainRoot(), 'worktree', 'add', rec.worktreePath, rec.branch]).then((result) => {
3214
+ if (!result.ok)
3215
+ throw new ResourceConflict(`git worktree add failed: ${result.stderr.trim() || result.failure}`);
3216
+ });
3217
+ if (!archive.ok)
3218
+ return;
3219
+ const patch = git(['-C', mainRoot(), 'diff', '--binary', `${rec.branch}..${ref}`]);
3220
+ if (!patch)
3221
+ return;
3222
+ try {
3223
+ execFileSync('git', ['-C', rec.worktreePath, 'apply', '--binary', '-'], { input: patch, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
3224
+ }
3225
+ catch (error) {
3226
+ throw new ResourceConflict(`session ${id} archived changes could not be restored: ${error instanceof Error ? error.message : String(error)}`);
3227
+ }
3228
+ }
2855
3229
  async function resumeSessionUnlocked(id, opts = {}) {
2856
3230
  const { force = false, guard = true } = opts;
2857
3231
  let wt;
@@ -2865,6 +3239,17 @@ async function resumeSessionUnlocked(id, opts = {}) {
2865
3239
  }
2866
3240
  if (!wt)
2867
3241
  return { ok: false, error: `no such session ${id}` };
3242
+ if (wt.rec.archived && retirementReason(wt.rec)) {
3243
+ try {
3244
+ await restoreArchivedWorktree(id, wt.rec);
3245
+ wt = await findWorktree(id);
3246
+ if (!wt)
3247
+ return { ok: false, error: `session ${id} disappeared while restoring its archived worktree` };
3248
+ }
3249
+ catch (error) {
3250
+ return { ok: false, refused: true, error: `session ${id}: archived worktree restore failed: ${error instanceof Error ? error.message : String(error)}` };
3251
+ }
3252
+ }
2868
3253
  // A process that died while validating left an internal candidate behind. This record lock proves no live
2869
3254
  // resume still owns it. Restore the frozen public original before doing any transport work and require an
2870
3255
  // explicit retry; stale runtime evidence is never adopted into a fresh launch attempt.
@@ -2891,19 +3276,19 @@ async function resumeSessionUnlocked(id, opts = {}) {
2891
3276
  // A prior adapter process may have proven identity + first-turn durability just before its session owner
2892
3277
  // died. Consume that receipt before choosing a recovery tail, so retry resumes the proven thread instead of
2893
3278
  // creating another one with the same first prompt.
2894
- if (h.launchPayloadProof && existsSync(sessionArtifactPath(id, 'launch.proof'))) {
3279
+ if (h.launchPayloadProof && hasReadableLaunchReceipt(id)) {
2895
3280
  consumeHarnessLaunchProofUnlocked(id);
2896
3281
  wt = await findWorktree(id);
2897
3282
  if (!wt)
2898
- return { ok: false, error: `session ${id} disappeared while recovering native launch proof` };
3283
+ return { ok: false, error: `session ${id} disappeared while recovering native launch receipt` };
2899
3284
  }
2900
3285
  // An archived record is expected to be stopped, but the guard must still inspect physical liveness in case
2901
3286
  // it is a legacy/invariant-violating row. Ignore filing and stale stop metadata for this one safety probe so
2902
3287
  // resume can never kill a live leaf merely because the record was hidden.
2903
3288
  const probeRec = wt.rec.archived ? { ...wt.rec, archived: false, stopped: false } : wt.rec;
2904
- const resumeSnap = await liveSnapshot();
3289
+ const resumeSnap = h.runtimeOwnership === 'adapter' ? null : await liveSnapshot();
2905
3290
  const lv = h.runtimeOwnership === 'adapter'
2906
- ? (resumeSnap.windows.has(id) ? 'online' : 'offline')
3291
+ ? await adapterRuntimeLiveness(probeRec)
2907
3292
  : liveness(probeRec, resumeSnap); // FRESH, honest liveness (listener-verified)
2908
3293
  if (guard && !force && lv === 'online')
2909
3294
  return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` };
@@ -2914,7 +3299,7 @@ async function resumeSessionUnlocked(id, opts = {}) {
2914
3299
  const recovery = await h.restoreRuntime?.(wt.rec);
2915
3300
  if (recovery && !recovery.ok)
2916
3301
  return { ok: false, refused: true, error: `session ${id}: recovery required before resume — ${recovery.reason}` };
2917
- writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, stopped: true });
3302
+ writeRecord({ ...(readRecord(id) || wt.rec), adapterRecovery: null, coldProof: null, archived: false, closedAt: null, stopped: true });
2918
3303
  wt = await findWorktree(id);
2919
3304
  if (!wt)
2920
3305
  return { ok: false, error: `session ${id} disappeared during adapter recovery` };
@@ -2923,8 +3308,8 @@ async function resumeSessionUnlocked(id, opts = {}) {
2923
3308
  // Make the durable row visible/offline before any adapter unarchive or launch RPC. Any later failure leaves
2924
3309
  // a retryable unarchived record rather than archived:true with a newly loaded target thread.
2925
3310
  const pendingRecovery = wt.rec.adapterRecovery || 'restore-runtime-pending';
2926
- writeRecord({ ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery });
2927
- const visible = readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery };
3311
+ writeRecord({ ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery });
3312
+ const visible = readRecord(id) || { ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: wt.rec.coldProof, adapterRecovery: pendingRecovery };
2928
3313
  const restored = await h.restoreRuntime?.(visible);
2929
3314
  if (restored && !restored.ok)
2930
3315
  return { ok: false, refused: true, error: `session ${id}: ${restored.reason}` };
@@ -2936,8 +3321,16 @@ async function resumeSessionUnlocked(id, opts = {}) {
2936
3321
  // fall through to a metadata-only no-op.
2937
3322
  // Archived sessions have no runtime by invariant. Resume first leaves cold storage, then the normal
2938
3323
  // starting -> online launch path recreates the same conversation.
2939
- const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, stopped: true, coldProof: null }) : wt.rec;
2940
- const resumed = { ...current, archived: false, coldProof: null, status: restingLifecycle(current.status), stopped: false };
3324
+ const current = wasArchived ? (readRecord(id) || { ...wt.rec, archived: false, closedAt: null, stopped: true, coldProof: null }) : wt.rec;
3325
+ const resumed = {
3326
+ ...current,
3327
+ archived: false,
3328
+ closedAt: null,
3329
+ coldProof: null,
3330
+ status: restingLifecycle(current.status),
3331
+ note: resumeNote(current.status, current.note),
3332
+ stopped: false,
3333
+ };
2941
3334
  if (force || lv === 'offline') {
2942
3335
  let resumeTail;
2943
3336
  try {
@@ -2969,8 +3362,10 @@ async function resumeSessionUnlocked(id, opts = {}) {
2969
3362
  const candidate = {
2970
3363
  ...latest,
2971
3364
  archived: false,
3365
+ closedAt: null,
2972
3366
  coldProof: null,
2973
3367
  status: restingLifecycle(latest.status),
3368
+ note: resumeNote(latest.status, latest.note),
2974
3369
  stopped: false,
2975
3370
  launchReadinessPending: launchReadinessPending(preResume),
2976
3371
  };
@@ -2994,11 +3389,19 @@ async function resumeSessionUnlocked(id, opts = {}) {
2994
3389
  error: `session ${id}: launch readiness changed across the pending publication${readinessError ? ` - ${readinessError}` : ''}; the session remains stopped and can be retried`,
2995
3390
  };
2996
3391
  }
2997
- const published = readRecord(id) || candidate;
3392
+ // `readRecord` projects the still-public pre-resume lifecycle while the candidate fence is pending.
3393
+ // Carrying that stale projection into the final publish used to leave queued/error rows unchanged in
3394
+ // SQLite even though the runtime envelope had crossed readiness. Publish the candidate lifecycle while
3395
+ // retaining the latest non-lifecycle envelope fields.
3396
+ const latestPublished = readRecord(id) || candidate;
3397
+ const published = { ...latestPublished, status: candidate.status, proposal: candidate.proposal, note: candidate.note };
3398
+ publishCanonicalLifecycle(published, candidate.status, candidate.proposal, candidate.note);
2998
3399
  writeRecord({ ...published, launchReadinessPending: null });
2999
3400
  }
3000
- else
3401
+ else {
3402
+ publishCanonicalLifecycle(current, resumed.status, resumed.proposal, resumed.note);
3001
3403
  writeRecord(resumed);
3404
+ }
3002
3405
  return { ok: true };
3003
3406
  }
3004
3407
  export const resumeSession = (id, opts = {}) => withSessionTransition(id, async () => {
@@ -3012,9 +3415,28 @@ export function markState(status, opts = {}) {
3012
3415
  if (!id)
3013
3416
  return false;
3014
3417
  return withRecordLockSync(id, () => {
3418
+ const raw = readRecord(id);
3419
+ if (raw?.archived)
3420
+ throw new ResourceConflict(`refusing lifecycle change for closed session ${id}: it is read-only; resume it before changing state`);
3015
3421
  const rec = readLiveRecord(id);
3016
- if (!rec)
3422
+ if (!rec?.governed)
3017
3423
  return false;
3424
+ const application = configuredSessionApplicationIfCutover();
3425
+ if (application) {
3426
+ const proposal = status === 'awaiting' ? (opts.proposal ?? 'nothing') : null;
3427
+ const note = opts.note ?? null;
3428
+ const current = application.readState(id);
3429
+ if (current && current.status === status && current.proposal === proposal && current.note === note)
3430
+ return true;
3431
+ const recipients = canonicalWatchRecipients(application, id, status);
3432
+ application.transitionSession(id, {
3433
+ status,
3434
+ proposal,
3435
+ note,
3436
+ recipientSessionIds: recipients,
3437
+ });
3438
+ return true;
3439
+ }
3018
3440
  const proposal = status === 'awaiting' ? (opts.proposal ?? 'nothing') : null;
3019
3441
  writeRecord({
3020
3442
  ...rec, status,
@@ -3024,15 +3446,40 @@ export function markState(status, opts = {}) {
3024
3446
  return true;
3025
3447
  });
3026
3448
  }
3449
+ // A human prompt is the explicit re-entry from a waiting turn; runtime liveness is not.
3450
+ export function markHumanPromptActive(sessionId) {
3451
+ try {
3452
+ const rec = readRecord(sessionId);
3453
+ const canonical = sessionHookState(sessionId);
3454
+ // The canonical lifecycle decides whether this record is writable. Any real human re-entry can
3455
+ // resume a waiting declaration, including an `awaiting` close/merge proposal; the old envelope
3456
+ // status is only migration metadata and must never veto the re-entry.
3457
+ if (!rec || !canonical || rec.archived || retirementReason(rec))
3458
+ return false;
3459
+ return markState('active', { sessionId });
3460
+ }
3461
+ catch (error) {
3462
+ // The message/PTY write is already accepted; a raced close or unreadable record must not turn it into a false send failure.
3463
+ console.error(`spex: could not publish human-input activity for ${sessionId}: ${error instanceof Error ? error.message : String(error)}`);
3464
+ return false;
3465
+ }
3466
+ }
3027
3467
  export const markDone = (proposal = 'nothing', sessionId, note) => markState('awaiting', { proposal, note, sessionId });
3028
- export const markError = (sessionId) => markState('error', { sessionId });
3029
3468
  export function markTurnFailure(sessionId, note) {
3030
3469
  if (!sessionId)
3031
3470
  return false;
3032
3471
  return withRecordLockSync(sessionId, () => {
3033
3472
  const rec = readLiveRecord(sessionId);
3034
- if (!rec || rec.status !== 'active' || rec.stopped || rec.archived)
3473
+ if (!rec?.governed || rec.status !== 'active' || rec.stopped || rec.archived)
3035
3474
  return false;
3475
+ const application = configuredSessionApplicationIfCutover();
3476
+ if (application) {
3477
+ application.transitionSession(sessionId, {
3478
+ status: 'error', proposal: null, note,
3479
+ recipientSessionIds: canonicalWatchRecipients(application, sessionId, 'error'),
3480
+ });
3481
+ return true;
3482
+ }
3036
3483
  writeRecord({ ...rec, status: 'error', proposal: null, note });
3037
3484
  return true;
3038
3485
  });
@@ -3063,8 +3510,22 @@ function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = proc
3063
3510
  registrationPrepared = true;
3064
3511
  }
3065
3512
  }
3513
+ const application = configuredSessionApplicationIfCutover();
3514
+ const nativeStartToken = rec.runtimeStartToken || process.env.SPEXCODE_NATIVE_START_TOKEN?.trim();
3515
+ if (application && !nativeStartToken)
3516
+ throw new ResourceConflict(`refusing to bind runtime for ${id}: native start token is missing`);
3066
3517
  try {
3067
3518
  writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null });
3519
+ if (application) {
3520
+ if (!nativeStartToken)
3521
+ throw new ResourceConflict(`refusing to bind runtime for ${id}: native start token is missing`);
3522
+ application.bindRuntime(id, {
3523
+ namespace: 'spex-governed',
3524
+ runtimeKind: rec.harness || defaultHarness.id,
3525
+ nativeSessionId: harnessSessionId,
3526
+ nativeStartToken,
3527
+ });
3528
+ }
3068
3529
  }
3069
3530
  catch (error) {
3070
3531
  if (codex && generationId && registrationPrepared) {
@@ -3080,9 +3541,26 @@ function bindHarnessSessionIdUnlocked(rec, harnessSessionId, generationId = proc
3080
3541
  if (codex && generationId)
3081
3542
  commitCodexGenerationRegistration(root, id, harnessSessionId, generationId);
3082
3543
  }
3083
- function readHarnessLaunchProof(id) {
3544
+ const NATIVE_LAUNCH_RECEIPT_FILE = 'launch.receipt';
3545
+ const LEGACY_NATIVE_LAUNCH_RECEIPT_FILE = 'launch.proof'; // dead-words-ok: one-release reader preserves staged receipts created before the protocol rename
3546
+ function launchReceiptPath(id) {
3547
+ return sessionArtifactPath(id, NATIVE_LAUNCH_RECEIPT_FILE);
3548
+ }
3549
+ function readableLaunchReceiptPath(id) {
3550
+ const current = launchReceiptPath(id);
3551
+ if (existsSync(current))
3552
+ return current;
3553
+ const legacy = sessionArtifactPath(id, LEGACY_NATIVE_LAUNCH_RECEIPT_FILE);
3554
+ return existsSync(legacy) ? legacy : null;
3555
+ }
3556
+ function hasReadableLaunchReceipt(id) {
3557
+ return readableLaunchReceiptPath(id) !== null;
3558
+ }
3559
+ function readHarnessLaunchProof(id, path = readableLaunchReceiptPath(id)) {
3560
+ if (!path)
3561
+ throw new ResourceConflict(`native launch receipt for ${id} is missing`);
3084
3562
  try {
3085
- const proof = JSON.parse(readFileSync(sessionArtifactPath(id, 'launch.proof'), 'utf8'));
3563
+ const proof = JSON.parse(readFileSync(path, 'utf8'));
3086
3564
  if (!proof || proof.version !== 1 || typeof proof.sessionId !== 'string'
3087
3565
  || typeof proof.harnessId !== 'string' || typeof proof.harnessSessionId !== 'string' || !proof.harnessSessionId
3088
3566
  || typeof proof.launchPayloadHash !== 'string'
@@ -3091,7 +3569,7 @@ function readHarnessLaunchProof(id) {
3091
3569
  return proof;
3092
3570
  }
3093
3571
  catch (error) {
3094
- throw new ResourceConflict(`native launch proof for ${id} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
3572
+ throw new ResourceConflict(`native launch receipt for ${id} is unreadable: ${error instanceof Error ? error.message : String(error)}`);
3095
3573
  }
3096
3574
  }
3097
3575
  function sameHarnessLaunchProof(left, right) {
@@ -3099,6 +3577,32 @@ function sameHarnessLaunchProof(left, right) {
3099
3577
  && left.harnessSessionId === right.harnessSessionId && left.launchPayloadHash === right.launchPayloadHash
3100
3578
  && left.generationId === right.generationId;
3101
3579
  }
3580
+ // A native launch can be proven just before its visible TUI exits. A shell-level retry must be able to ask
3581
+ // for that exact target without replaying the first prompt. This read-only resolver accepts either side of the
3582
+ // proof-consumption boundary: the durable record after identity binding, or the staged receipt while the record
3583
+ // lock has not consumed it yet. Any mismatch remains a loud resource conflict; returning null means no proof has
3584
+ // been established and a fresh first-turn attempt is still allowed.
3585
+ export function existingHarnessLaunchTarget(id) {
3586
+ const rec = readLiveRecord(id);
3587
+ if (!rec)
3588
+ return null;
3589
+ const harness = harnessById(rec.harness || defaultHarness.id);
3590
+ if (!harness.launchPayloadProof)
3591
+ return null;
3592
+ const receiptPath = readableLaunchReceiptPath(id);
3593
+ if (receiptPath) {
3594
+ const proof = readHarnessLaunchProof(id, receiptPath);
3595
+ if (proof.sessionId !== id || proof.harnessId !== harness.id)
3596
+ throw new ResourceConflict(`native launch receipt for ${id} does not match the governed adapter identity`);
3597
+ const pending = readLaunchFile(id);
3598
+ if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
3599
+ throw new ResourceConflict(`native launch receipt for ${id} does not match the authoritative resolved launch payload`);
3600
+ if (rec.harnessSessionId && rec.harnessSessionId !== proof.harnessSessionId)
3601
+ throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; staged launch receipt differs from the record`);
3602
+ return proof.harnessSessionId;
3603
+ }
3604
+ return rec.harnessSessionId || null;
3605
+ }
3102
3606
  export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPayload) {
3103
3607
  const id = sessionId || ownSessionId();
3104
3608
  if (!id || !harnessSessionId)
@@ -3108,17 +3612,17 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
3108
3612
  return false;
3109
3613
  const harness = harnessById(rec.harness || defaultHarness.id);
3110
3614
  if (!harness.launchPayloadProof)
3111
- throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload proof`);
3615
+ throw new ResourceConflict(`harness ${harness.id} does not use native launch-payload receipts`);
3112
3616
  const pending = readLaunchFile(id);
3113
3617
  if (pending == null)
3114
- throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
3618
+ throw new ResourceConflict(`refusing native launch receipt for ${id}: authoritative resolved launch payload is missing`);
3115
3619
  if (pending !== launchPayload)
3116
- throw new ResourceConflict(`refusing native launch proof for ${id}: first-turn payload differs from the authoritative resolved launch payload`);
3620
+ throw new ResourceConflict(`refusing native launch receipt for ${id}: first-turn payload differs from the authoritative resolved launch payload`);
3117
3621
  const generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim() || null;
3118
3622
  if (rec.harness === 'codex' || rec.harness === 'codex-headless') {
3119
3623
  const ledger = readCodexGenerationLedger(runtimeRoot());
3120
3624
  if (ledger.revision > 0 && !generationId)
3121
- throw new ResourceConflict(`refusing native launch proof for ${id}: launch did not provide an exact Codex generation id`);
3625
+ throw new ResourceConflict(`refusing native launch receipt for ${id}: launch did not provide an exact Codex generation id`);
3122
3626
  if (generationId && (!ledger.generations[generationId] || ledger.generations[generationId].state === 'reclaimed'))
3123
3627
  throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: generation ${generationId} is absent or reclaimed`);
3124
3628
  }
@@ -3130,7 +3634,14 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
3130
3634
  launchPayloadHash: createHash('sha256').update(launchPayload).digest('hex'),
3131
3635
  generationId,
3132
3636
  };
3133
- const path = sessionArtifactPath(id, 'launch.proof');
3637
+ const existingPath = readableLaunchReceiptPath(id);
3638
+ if (existingPath) {
3639
+ const staged = readHarnessLaunchProof(id, existingPath);
3640
+ if (sameHarnessLaunchProof(staged, proof))
3641
+ return true;
3642
+ throw new ResourceConflict(`refusing to replace native launch receipt for ${id}: the staged session, thread, payload, or generation differs`);
3643
+ }
3644
+ const path = launchReceiptPath(id);
3134
3645
  const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
3135
3646
  writeFileSync(temp, `${JSON.stringify(proof, null, 2)}\n`, { mode: 0o600 });
3136
3647
  try {
@@ -3140,10 +3651,10 @@ export function stageHarnessLaunchProof(sessionId, harnessSessionId, launchPaylo
3140
3651
  catch (error) {
3141
3652
  if (error.code !== 'EEXIST')
3142
3653
  throw error;
3143
- const staged = readHarnessLaunchProof(id);
3654
+ const staged = readHarnessLaunchProof(id, path);
3144
3655
  if (sameHarnessLaunchProof(staged, proof))
3145
3656
  return true;
3146
- throw new ResourceConflict(`refusing to replace native launch proof for ${id}: the staged session, thread, payload, or generation differs`);
3657
+ throw new ResourceConflict(`refusing to replace native launch receipt for ${id}: the staged session, thread, payload, or generation differs`);
3147
3658
  }
3148
3659
  finally {
3149
3660
  rmSync(temp, { force: true });
@@ -3154,14 +3665,15 @@ function consumeHarnessLaunchProofUnlocked(id) {
3154
3665
  if (!rec)
3155
3666
  return false;
3156
3667
  const harness = harnessById(rec.harness || defaultHarness.id);
3157
- const proof = readHarnessLaunchProof(id);
3668
+ const receiptPath = readableLaunchReceiptPath(id);
3669
+ const proof = readHarnessLaunchProof(id, receiptPath);
3158
3670
  if (proof.sessionId !== id || proof.harnessId !== harness.id)
3159
- throw new ResourceConflict(`native launch proof for ${id} does not match the governed adapter identity`);
3671
+ throw new ResourceConflict(`native launch receipt for ${id} does not match the governed adapter identity`);
3160
3672
  const pending = readLaunchFile(id);
3161
3673
  if (pending == null && rec.harnessSessionId !== proof.harnessSessionId)
3162
- throw new ResourceConflict(`refusing native launch proof for ${id}: authoritative resolved launch payload is missing`);
3674
+ throw new ResourceConflict(`refusing native launch receipt for ${id}: authoritative resolved launch payload is missing`);
3163
3675
  if (pending != null && proof.launchPayloadHash !== createHash('sha256').update(pending).digest('hex'))
3164
- throw new ResourceConflict(`native launch proof for ${id} does not match the authoritative resolved launch payload`);
3676
+ throw new ResourceConflict(`native launch receipt for ${id} does not match the authoritative resolved launch payload`);
3165
3677
  bindHarnessSessionIdUnlocked(rec, proof.harnessSessionId, proof.generationId || undefined);
3166
3678
  if (pending != null) {
3167
3679
  try {
@@ -3169,17 +3681,18 @@ function consumeHarnessLaunchProofUnlocked(id) {
3169
3681
  }
3170
3682
  catch (error) {
3171
3683
  if (error.code !== 'ENOENT') {
3172
- console.error(`spex: native launch proof committed for ${id}, but launch could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3684
+ console.error(`spex: native launch receipt committed for ${id}, but launch could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3173
3685
  return true;
3174
3686
  }
3175
3687
  }
3176
3688
  }
3177
3689
  try {
3178
- rmSync(sessionArtifactPath(id, 'launch.proof'));
3690
+ if (receiptPath)
3691
+ rmSync(receiptPath);
3179
3692
  }
3180
3693
  catch (error) {
3181
3694
  if (error.code !== 'ENOENT')
3182
- console.error(`spex: native launch proof committed for ${id}, but launch.proof could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3695
+ console.error(`spex: native launch receipt committed for ${id}, but the receipt could not be consumed: ${error instanceof Error ? error.message : String(error)}`);
3183
3696
  }
3184
3697
  return true;
3185
3698
  }
@@ -3193,7 +3706,7 @@ export function markHarnessSessionId(sessionId, harnessSessionId) {
3193
3706
  return false;
3194
3707
  const harness = harnessById(rec.harness || defaultHarness.id);
3195
3708
  if (harness.launchPayloadProof)
3196
- throw new ResourceConflict(`harness ${harness.id} must stage native identity together with authoritative first-turn payload proof`);
3709
+ throw new ResourceConflict(`harness ${harness.id} must stage native identity together with an authoritative first-turn payload receipt`);
3197
3710
  bindHarnessSessionIdUnlocked(rec, harnessSessionId);
3198
3711
  return true;
3199
3712
  });
@@ -3204,9 +3717,13 @@ export function markIdle(sessionId) {
3204
3717
  return false;
3205
3718
  return withRecordLockSync(id, () => {
3206
3719
  const rec = readLiveRecord(id);
3207
- if (!rec || rec.status !== 'active')
3208
- return false; // active-only: never clobber a declaration
3209
- writeRecord({ ...rec, status: 'idle' });
3720
+ if (!rec?.governed || rec.status !== 'active')
3721
+ return false; // managed active-only: never clobber a declaration
3722
+ publishCanonicalLifecycle(rec, 'idle', null, null);
3723
+ // After cutover the JSON file is only the runtime/worktree envelope. Do not mirror this inferred
3724
+ // lifecycle transition into it: doing so creates a second, stale-looking status surface for hooks.
3725
+ if (!configuredSessionApplicationIfCutover())
3726
+ writeRecord({ ...rec, status: 'idle' });
3210
3727
  return true;
3211
3728
  });
3212
3729
  }
@@ -3244,6 +3761,265 @@ function porcelainPath(line) {
3244
3761
  p = p.slice(arrow + 4);
3245
3762
  return p;
3246
3763
  }
3764
+ export function commitUrlForRemote(remote, commit) {
3765
+ const raw = remote.trim();
3766
+ let host = '', path = '';
3767
+ try {
3768
+ const url = new URL(raw);
3769
+ if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'ssh:') {
3770
+ host = url.host;
3771
+ path = url.pathname;
3772
+ }
3773
+ }
3774
+ catch {
3775
+ const scp = /^(?:[^@/]+@)?([^:/]+):(.+)$/.exec(raw);
3776
+ if (scp)
3777
+ [, host, path] = scp;
3778
+ }
3779
+ path = path.replace(/^\/+|\/+$/g, '').replace(/\.git$/, '');
3780
+ if (!host || !path)
3781
+ return null;
3782
+ const commitPath = host.toLowerCase().includes('gitlab') ? '-/commit' : 'commit';
3783
+ return `https://${host}/${path}/${commitPath}/${commit}`;
3784
+ }
3785
+ // The branch diff is a proof over commits, not over a working directory: refs and objects are shared with
3786
+ // the main checkout, so a session whose worktree directory is gone (landed and cleaned, or reaped) keeps a
3787
+ // provable diff for as long as its branch ref survives. Anchor git at the live worktree when it exists and
3788
+ // at the main checkout otherwise; only a branch whose ref is gone everywhere is honestly unavailable, and
3789
+ // that refusal is a structured conflict (409 {error, code}) — never a raw git ENOENT turned into a 500.
3790
+ async function diffAnchorRoot(wt) {
3791
+ if (!wt.branch)
3792
+ throw new ResourceConflict(`session ${wt.rec.session} has no branch to diff`, 'diff-unavailable');
3793
+ if (wt.path && existsSync(wt.path))
3794
+ return wt.path;
3795
+ const main = mainRoot();
3796
+ const proven = await gitTry(['-C', main, 'rev-parse', '--verify', `refs/heads/${wt.branch}^{commit}`]);
3797
+ if (proven.ok)
3798
+ return main;
3799
+ throw new ResourceConflict(`session ${wt.rec.session} has no worktree on disk and its branch ${wt.branch} no longer exists`, 'diff-unavailable');
3800
+ }
3801
+ // @@@ forkCommitOf - the commit the branch was created at, from the most authoritative source that has it.
3802
+ // The record carries it for every session created since it was introduced. Older records recover the same
3803
+ // commit from the branch ref's OLDEST reflog entry, which is where git itself wrote the `worktree add` start
3804
+ // point. Neither available (reflog pruned, or a branch adopted from outside this flow) → null, and the caller
3805
+ // falls back to what ancestry alone can prove.
3806
+ async function forkCommitOf(root, wt) {
3807
+ if (wt.rec.forkCommit && isGitObjectId(root, wt.rec.forkCommit))
3808
+ return wt.rec.forkCommit;
3809
+ if (!wt.branch)
3810
+ return null;
3811
+ const log = await gitTry(['-C', root, 'reflog', 'show', '--no-abbrev', '--format=%H', `refs/heads/${wt.branch}`]);
3812
+ if (!log.ok)
3813
+ return null;
3814
+ const entries = log.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
3815
+ const created = entries[entries.length - 1];
3816
+ return created && isGitObjectId(root, created) ? created : null;
3817
+ }
3818
+ async function diffHeadPair(root, wt) {
3819
+ if (!wt.branch)
3820
+ throw new ResourceConflict(`session ${wt.rec.session} has no branch to diff`, 'diff-unavailable');
3821
+ const baseRef = wt.rec.base || mainBranch();
3822
+ const [headOut, baseOut] = await Promise.all([
3823
+ gitTry(['-C', root, 'rev-parse', '--verify', `refs/heads/${wt.branch}^{commit}`]),
3824
+ gitTry(['-C', root, 'rev-parse', '--verify', `${baseRef}^{commit}`]),
3825
+ ]);
3826
+ const head = headOut.ok ? headOut.stdout.trim() : '', resolvedBase = baseOut.ok ? baseOut.stdout.trim() : '';
3827
+ if (!head || !resolvedBase || !isGitObjectId(root, head) || !isGitObjectId(root, resolvedBase))
3828
+ throw new ResourceConflict(`session ${wt.rec.session} diff heads are unproven`, 'diff-unavailable');
3829
+ const mergeBaseOut = await gitTry(['-C', root, 'merge-base', resolvedBase, head]);
3830
+ const mergeBase = mergeBaseOut.ok ? mergeBaseOut.stdout.trim() : '';
3831
+ if (!mergeBase || !isGitObjectId(root, mergeBase))
3832
+ throw new ResourceConflict(`session ${wt.rec.session} diff merge-base is unproven`, 'diff-unavailable');
3833
+ const [ancestor, remote, forkCommit] = await Promise.all([
3834
+ gitTry(['-C', root, 'merge-base', '--is-ancestor', head, resolvedBase]),
3835
+ gitTry(['-C', root, 'remote', 'get-url', 'origin']),
3836
+ forkCommitOf(root, wt),
3837
+ ]);
3838
+ // A branch that never authored a commit is ALSO an ancestor of its base, so ancestry must be asked second.
3839
+ // Without a fork commit the only provable form of "authored nothing" is a head that is still the base head.
3840
+ const authoredNothing = forkCommit ? head === forkCommit : head === resolvedBase;
3841
+ return {
3842
+ branch: wt.branch, baseRef, base: resolvedBase, head, mergeBase,
3843
+ branchState: authoredNothing ? 'no-commits' : ancestor.ok ? 'merged' : 'open',
3844
+ commitUrl: remote.ok ? commitUrlForRemote(remote.stdout, head) : null,
3845
+ };
3846
+ }
3847
+ // @@@ workingFiles - the session's uncommitted changes, enumerated from ONE porcelain status plus ONE numstat.
3848
+ // Untracked files count their own lines rather than spawning a git child each: the metadata call stays two
3849
+ // processes however dirty the tree is, and nothing here touches the index — an `--intent-to-add` would mutate
3850
+ // the worktree a live agent is working in.
3851
+ const WORKING_STATUS = { '??': 'untracked', A: 'added', D: 'deleted', R: 'renamed', C: 'copied', T: 'type-changed' };
3852
+ async function workingFiles(root) {
3853
+ const [statusOut, numstatOut] = await Promise.all([
3854
+ gitA(['-C', root, '-c', 'core.quotePath=false', 'status', '--porcelain', '--untracked-files=all']),
3855
+ gitA(['-C', root, '-c', 'core.quotePath=false', 'diff', '--numstat', '-M', 'HEAD']),
3856
+ ]);
3857
+ const counts = new Map();
3858
+ for (const line of numstatOut.split('\n')) {
3859
+ const m = line.match(/^(-|\d+)\t(-|\d+)\t(.+)$/);
3860
+ if (!m)
3861
+ continue;
3862
+ const { to } = parseStatPath(m[3]);
3863
+ counts.set(to, { additions: m[1] === '-' ? 0 : +m[1], deletions: m[2] === '-' ? 0 : +m[2] });
3864
+ }
3865
+ const files = [];
3866
+ for (const line of statusOut.split('\n')) {
3867
+ if (!line.trim())
3868
+ continue;
3869
+ const code = line.slice(0, 2);
3870
+ const path = porcelainPath(line);
3871
+ const arrow = line.indexOf(' -> ');
3872
+ const oldPath = arrow >= 0 ? line.slice(3, arrow) : '';
3873
+ const letter = code.trim().replace(/[^A-Z?]/g, '').slice(0, 1) || 'M';
3874
+ const status = WORKING_STATUS[code === '??' ? '??' : letter] ?? 'modified';
3875
+ files.push({
3876
+ path,
3877
+ ...(oldPath && oldPath !== path ? { oldPath } : {}),
3878
+ status,
3879
+ ...(counts.get(path) ?? (code === '??' ? untrackedCounts(join(root, path)) : { additions: 0, deletions: 0 })),
3880
+ });
3881
+ }
3882
+ return files.sort((a, b) => a.path.localeCompare(b.path));
3883
+ }
3884
+ // An untracked file is entirely new, so its addition count is its line count. A NUL byte means git would
3885
+ // print `-`/`-` for a binary blob; report the same nothing rather than a line count of bytes.
3886
+ function untrackedCounts(absolute) {
3887
+ try {
3888
+ const body = readFileSync(absolute);
3889
+ if (body.includes(0))
3890
+ return { additions: 0, deletions: 0 };
3891
+ const text = body.toString('utf8');
3892
+ return { additions: text.length ? text.replace(/\n$/, '').split('\n').length : 0, deletions: 0 };
3893
+ }
3894
+ catch {
3895
+ return { additions: 0, deletions: 0 };
3896
+ }
3897
+ }
3898
+ async function workingPatch(root, file, untracked) {
3899
+ if (untracked) {
3900
+ // --no-index against /dev/null renders a whole new file as one addition hunk. It exits 1 when the two
3901
+ // sides differ, which is the normal case here, so the patch is read off stdout rather than off `ok`.
3902
+ const out = await gitTry(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', '--no-index', '--', '/dev/null', file.path]);
3903
+ return out.stdout;
3904
+ }
3905
+ return gitA(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', 'HEAD', '--', ...(file.oldPath ? [file.oldPath, file.path] : [file.path])]);
3906
+ }
3907
+ // A working file's identity must move when its CONTENT moves, or a stale editor and a stale comment anchor
3908
+ // would survive an edit. Size and mtime are what change on every write, and they cost one stat.
3909
+ function workingIdentity(root, file) {
3910
+ let stamp = 'gone';
3911
+ try {
3912
+ const s = statSync(join(root, file.path));
3913
+ stamp = `${s.size}:${s.mtimeMs}`;
3914
+ }
3915
+ catch { /* deleted in the worktree */ }
3916
+ return createHash('sha256').update(`working\0${file.path}\0${file.oldPath || ''}\0${stamp}`).digest('hex');
3917
+ }
3918
+ export async function sessionDiff(id, filePath, offset = 0, limit = 120_000, scope = 'branch') {
3919
+ const wt = await findWorktree(id);
3920
+ if (!wt)
3921
+ return null;
3922
+ const root = await diffAnchorRoot(wt);
3923
+ const pair = await diffHeadPair(root, wt);
3924
+ // The working tree is the session's OWN directory or it is not knowable. `root` falls back to the main
3925
+ // checkout once the worktree is gone ([[diff-document]]), and that checkout's dirty files belong to whoever
3926
+ // is working there — never to this session.
3927
+ const liveRoot = wt.path && existsSync(wt.path) ? wt.path : null;
3928
+ const window = (patch) => patch.slice(offset, offset + limit);
3929
+ // A per-file fetch names its scope, so only that scope is enumerated: opening one file in a worktree with a
3930
+ // hundred dirty paths must not re-walk the other scope's git reads.
3931
+ const branch = scope === 'branch' || !filePath ? await mergeBaseDiff(root, pair.base, pair.head) : [];
3932
+ const branchSelected = scope === 'branch' && filePath ? branch.filter((file) => file.path === filePath || file.oldPath === filePath) : (filePath ? [] : branch);
3933
+ const files = await Promise.all(branchSelected.map(async (file) => {
3934
+ const identity = createHash('sha256').update(`${pair.mergeBase}\0${pair.head}\0${file.path}\0${file.oldPath || ''}`).digest('hex');
3935
+ if (!filePath)
3936
+ return { ...file, patch: '', diffIdentity: identity };
3937
+ const patch = await gitA(['-C', root, '--no-pager', 'diff', '--no-ext-diff', '--unified=40', pair.mergeBase, pair.head, '--', ...(file.oldPath ? [file.oldPath, file.path] : [file.path])]);
3938
+ return { ...file, patch: window(patch), diffIdentity: identity };
3939
+ }));
3940
+ const dirty = liveRoot && (scope === 'working' || !filePath) ? await workingFiles(liveRoot) : [];
3941
+ const workingSelected = scope === 'working' && filePath ? dirty.filter((file) => file.path === filePath || file.oldPath === filePath) : (filePath ? [] : dirty);
3942
+ const working = await Promise.all(workingSelected.map(async (file) => {
3943
+ const identity = workingIdentity(liveRoot, file);
3944
+ if (!filePath)
3945
+ return { ...file, patch: '', diffIdentity: identity };
3946
+ const patch = await workingPatch(liveRoot, file, file.status === 'untracked');
3947
+ return { ...file, patch: window(patch), diffIdentity: identity };
3948
+ }));
3949
+ return {
3950
+ id, scope: 'branch', ...pair, files,
3951
+ working: { readable: !!liveRoot, files: working },
3952
+ comments: wt.rec.diffComments ?? [],
3953
+ };
3954
+ }
3955
+ export async function saveDiffComment(id, input) {
3956
+ const body = input.body.trim();
3957
+ if (!input.filePath || !body || !Number.isInteger(input.lineStart) || input.lineStart < 1 || !Number.isInteger(input.lineEnd) || input.lineEnd < input.lineStart || !input.diffIdentity)
3958
+ throw new ResourceConflict('diff comment needs a file, line range, body, and diff identity');
3959
+ return withRecordLock(id, async () => {
3960
+ const rec = readLiveRecord(id);
3961
+ if (!rec)
3962
+ return null;
3963
+ const comment = { id: input.id || randomUUID(), filePath: input.filePath, lineStart: input.lineStart, lineEnd: input.lineEnd, body, diffIdentity: input.diffIdentity, sentAt: null };
3964
+ const comments = (rec.diffComments ?? []).filter((candidate) => candidate.id !== comment.id);
3965
+ writeRecord({ ...rec, diffComments: [...comments, comment] });
3966
+ return comment;
3967
+ });
3968
+ }
3969
+ // A review conversation you can only append to is not a conversation. Saving, editing and sending all
3970
+ // existed; nothing could take a row back, so a comment filed on the wrong line — or a probe left by a
3971
+ // measurement — stayed on the record forever. Retract is the same shape as the other two `retract` verbs
3972
+ // this product already has ([[session-files]], eval): it removes the row under the record lock and says
3973
+ // which one it removed. Already-DELIVERED text is not recalled — the agent read it — so this retracts the
3974
+ // record's row, never the message that was sent.
3975
+ export async function retractDiffComment(id, commentId) {
3976
+ if (!commentId)
3977
+ throw new ResourceConflict('retracting a diff comment needs its id');
3978
+ return withRecordLock(id, async () => {
3979
+ const rec = readLiveRecord(id);
3980
+ if (!rec)
3981
+ return null;
3982
+ const comments = rec.diffComments ?? [];
3983
+ const removed = comments.find((comment) => comment.id === commentId);
3984
+ if (!removed)
3985
+ return null;
3986
+ writeRecord({ ...rec, diffComments: comments.filter((comment) => comment.id !== commentId) });
3987
+ return removed;
3988
+ });
3989
+ }
3990
+ export async function sendDiffComments(id, ids) {
3991
+ const selected = await withRecordLock(id, async () => {
3992
+ const rec = readLiveRecord(id);
3993
+ if (!rec)
3994
+ return null;
3995
+ const wanted = ids?.length ? new Set(ids) : null;
3996
+ return (rec.diffComments ?? []).filter((comment) => !comment.sentAt && (!wanted || wanted.has(comment.id)));
3997
+ });
3998
+ if (!selected)
3999
+ return { ok: false, error: `no such session ${id}` };
4000
+ if (!selected.length)
4001
+ return { ok: false, error: 'no unsent diff comments' };
4002
+ const text = ['Review comments on the branch diff:', ...selected.map((comment) => {
4003
+ const lines = comment.lineStart === comment.lineEnd ? `L${comment.lineStart}` : `L${comment.lineStart}-L${comment.lineEnd}`;
4004
+ return `- ${comment.filePath}:${lines}\n ${comment.body.replace(/\n/g, '\n ')}`;
4005
+ })].join('\n');
4006
+ const sent = await sendText(id, text);
4007
+ if (!sent.ok)
4008
+ return { ok: false, error: sent.error || 'could not send diff comments' };
4009
+ const sentAt = new Date().toISOString();
4010
+ await withRecordLock(id, async () => {
4011
+ const rec = readLiveRecord(id);
4012
+ if (!rec)
4013
+ return;
4014
+ const selectedById = new Map(selected.map((comment) => [comment.id, comment]));
4015
+ writeRecord({ ...rec, diffComments: (rec.diffComments ?? []).map((comment) => {
4016
+ const before = selectedById.get(comment.id);
4017
+ const unchanged = before && !comment.sentAt && comment.body === before.body && comment.diffIdentity === before.diffIdentity;
4018
+ return unchanged ? { ...comment, sentAt } : comment;
4019
+ }) });
4020
+ });
4021
+ return { ok: true, sentAt, count: selected.length };
4022
+ }
3247
4023
  async function reviewHeadPair(root, branch, base) {
3248
4024
  const branchRef = `refs/heads/${branch}`, baseRef = `refs/heads/${base}`;
3249
4025
  const output = await gitA(['-C', root, 'for-each-ref', '--sort=refname', '--format=%(refname)%00%(objectname)', branchRef, baseRef]);
@@ -3350,16 +4126,7 @@ export async function mergeSession(id) {
3350
4126
  return { dispatched: false, reason: 'no such mergeable session' };
3351
4127
  const r = await sendText(id, MERGE_PROMPT, undefined, {
3352
4128
  deferDrain: true,
3353
- acceptGuard: async (rec) => {
3354
- if (!rec.governed || rec.status !== 'awaiting' || rec.proposal !== 'merge') {
3355
- const error = new ResourceConflict(`session ${id} is not a governed awaiting merge proposal`);
3356
- Object.assign(error, { code: 'session_merge_not_proposed' });
3357
- throw error;
3358
- }
3359
- },
3360
4129
  });
3361
- if (r.code === 'session_merge_not_proposed')
3362
- return { dispatched: false, reason: r.error || 'merge dispatch refused', code: r.code, status: 409 };
3363
4130
  if (!r.ok)
3364
4131
  return { dispatched: false, reason: r.error || 'could not dispatch merge prompt' };
3365
4132
  await resumeSession(id, { guard: false });
@@ -3627,7 +4394,7 @@ async function inspectSessionLeafIdentity(id, rec) {
3627
4394
  const startAfter = sessionLeafStartToken(pid);
3628
4395
  const candidate = sessionLeafReceiptCandidate(id, pid, panePid, procs, startBefore, startAfter);
3629
4396
  if (!candidate.ok || !candidate.receipt)
3630
- return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt proof failed' };
4397
+ return { state: 'unknown', pid, reason: candidate.reason || 'leaf birth receipt validation failed' };
3631
4398
  if (readAgentPid(path) !== pid || sessionLeafStartToken(pid) !== candidate.receipt.startToken)
3632
4399
  return { state: 'unknown', pid, reason: `leaf PID ${pid} identity changed before receipt commit` };
3633
4400
  writeSessionLeafReceipt(id, candidate.receipt);
@@ -3681,7 +4448,7 @@ async function stopAgentProcess(id, rec, requireCold = false, coldReceipt) {
3681
4448
  if (requireCold) {
3682
4449
  const cold = await harness.coldRuntime?.(rec, coldReceipt);
3683
4450
  if (cold && !cold.ok)
3684
- throw new ResourceConflict(`refusing to archive ${id}: ${cold.reason}`);
4451
+ throw new ResourceConflict(`refusing to close ${id}: ${cold.reason}`);
3685
4452
  }
3686
4453
  }
3687
4454
  async function stopSessionUnlocked(id) {
@@ -3706,25 +4473,23 @@ async function stopSessionUnlocked(id) {
3706
4473
  return !!wt;
3707
4474
  }
3708
4475
  export const stopSession = (id) => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)));
3709
- async function archiveSessionUnlocked(id, on = true) {
4476
+ async function coldStopSessionUnlocked(id) {
3710
4477
  let wt;
3711
4478
  try {
3712
4479
  wt = await findWorktree(id);
3713
4480
  }
3714
4481
  catch (e) {
3715
4482
  if (e instanceof SessionRecordUnusable)
3716
- throw new ResourceConflict(`refusing to archive ${id}: ${e.message}`);
4483
+ throw new ResourceConflict(`refusing to close ${id}: ${e.message}`);
3717
4484
  throw e;
3718
4485
  }
3719
4486
  if (!wt)
3720
4487
  return false;
3721
- if (!on)
3722
- throw new ResourceConflict('unarchive is not a record-only transition; use resume to restore the runtime');
3723
4488
  if (wt.rec.status === 'queued')
3724
- throw new ResourceConflict(`refusing to archive ${id}: queued sessions have only a prepared launch prompt; resume/startQueued is their lifecycle`);
4489
+ throw new ResourceConflict(`refusing to close ${id}: queued sessions have only a prepared launch prompt; close handles their prepared tree directly`);
3725
4490
  const retired = retirementReason(wt.rec);
3726
4491
  if (retired)
3727
- throw new ResourceConflict(`refusing to archive ${id}: ${retired}`);
4492
+ throw new ResourceConflict(`refusing to close ${id}: ${retired}`);
3728
4493
  archiving.add(id);
3729
4494
  try {
3730
4495
  const h = harnessById(wt.rec.harness || defaultHarness.id);
@@ -3738,12 +4503,12 @@ async function archiveSessionUnlocked(id, on = true) {
3738
4503
  if (wt.rec.archived && hasValidColdProof(wt.rec)) {
3739
4504
  const proofSnap = await liveSnapshot(id);
3740
4505
  if (proofSnap.probeFailed)
3741
- throw new ResourceConflict(`refusing to re-archive ${id}: liveness probe failed; the exact leaf may have respawned`);
4506
+ throw new ResourceConflict(`refusing to close ${id}: liveness probe failed; the exact leaf may have respawned`);
3742
4507
  const proofLv = h.runtimeOwnership === 'adapter'
3743
4508
  ? (proofSnap.windows.has(id) ? 'online' : 'offline')
3744
4509
  : liveness({ ...wt.rec, archived: false, stopped: false }, proofSnap);
3745
4510
  if (proofLv === 'unknown' || proofLv === 'starting')
3746
- throw new ResourceConflict(`refusing to re-archive ${id}: session liveness is ${proofLv}; exact cold state is unproven`);
4511
+ throw new ResourceConflict(`refusing to close ${id}: session liveness is ${proofLv}; exact cold state is unproven`);
3747
4512
  if (proofLv === 'offline') {
3748
4513
  // A deliberately stopped shared control plane is a valid empty resident census. A durable proof plus
3749
4514
  // an adapter-owned root-absent fact is the only idempotent short-circuit; a live root still has to prove
@@ -3768,28 +4533,25 @@ async function archiveSessionUnlocked(id, on = true) {
3768
4533
  }
3769
4534
  }
3770
4535
  }
3771
- // Legacy/respawned archives are made visible before repair. Any refusal below therefore leaves an unarchived
3772
- // row instead of relying on a hidden bit while a runtime proof is missing.
4536
+ // Legacy/respawned closed rows are probed as ordinary runtime records, but the durable archived bit is kept
4537
+ // untouched until the new close publication succeeds.
3773
4538
  if (wt.rec.archived) {
3774
- writeRecord({ ...wt.rec, archived: false, coldProof: null });
3775
- wt = await findWorktree(id);
3776
- if (!wt)
3777
- return false;
4539
+ wt = { ...wt, rec: { ...wt.rec, archived: false, coldProof: null } };
3778
4540
  }
3779
4541
  const snap = await liveSnapshot(id);
3780
4542
  if (snap.probeFailed)
3781
- throw new ResourceConflict(`refusing to archive ${id}: liveness probe failed; the leaf may still be live`);
4543
+ throw new ResourceConflict(`refusing to close ${id}: liveness probe failed; the leaf may still be live`);
3782
4544
  const lv = h.runtimeOwnership === 'adapter'
3783
4545
  ? 'offline'
3784
4546
  : liveness({ ...wt.rec, archived: false, stopped: false }, snap);
3785
4547
  if (lv === 'unknown' || lv === 'starting')
3786
- throw new ResourceConflict(`refusing to archive ${id}: session liveness is ${lv}; exact leaf ownership is unproven`);
4548
+ throw new ResourceConflict(`refusing to close ${id}: session liveness is ${lv}; exact leaf ownership is unproven`);
3787
4549
  // The adapter guard runs BEFORE any tmux/process signal. Active/unknown native turns and ambiguous descendant
3788
4550
  // ownership refuse here; a verified adapter receipt carries an exact subtree through to coldRuntime's commit.
3789
4551
  assertSessionOwnerSafe(id, h.id);
3790
4552
  const preflight = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: lv === 'offline' });
3791
4553
  if (preflight && !preflight.ok)
3792
- throw new ResourceConflict(`refusing to archive ${id}: ${preflight.reason}`);
4554
+ throw new ResourceConflict(`refusing to close ${id}: ${preflight.reason}`);
3793
4555
  // Even a proven-offline leaf can leave a stale rendezvous/socket or adapter artifact. Reuse the same exact
3794
4556
  // teardown seam with the explicit stopped marker so cleanupRuntime gets its ownership check and no second
3795
4557
  // cleanup primitive is invented.
@@ -3801,12 +4563,12 @@ async function archiveSessionUnlocked(id, on = true) {
3801
4563
  coldCommitted = true;
3802
4564
  const latest = readRecord(id);
3803
4565
  if (!latest)
3804
- throw new ResourceConflict(`refusing to archive ${id}: session record disappeared before filing`);
4566
+ throw new ResourceConflict(`refusing to close ${id}: session record disappeared before archive-ref publication`);
3805
4567
  // The leaf identity kill and adapter cold proof established process/transport absence. Record-backed
3806
4568
  // adapters intentionally project online until the archive write, so display liveness is not physical
3807
4569
  // evidence here. A target pane appearing after the stop proof is the remaining shared runtime witness.
3808
4570
  await assertTargetTmuxAbsent(id, 'before archive filing');
3809
- writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null });
4571
+ writeRecord({ ...latest, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null });
3810
4572
  }
3811
4573
  catch (error) {
3812
4574
  if (coldCommitted) {
@@ -3832,102 +4594,10 @@ async function archiveSessionUnlocked(id, on = true) {
3832
4594
  archiving.delete(id);
3833
4595
  }
3834
4596
  }
3835
- export const archiveSession = (id, on = true) => {
3836
- if (!on)
3837
- return archiveSessionUnarchive(id);
3838
- return withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on)));
3839
- };
3840
- async function archiveSessionUnarchive(id) {
3841
- const wt = await findWorktree(id);
3842
- if (!wt)
3843
- return false;
3844
- if (!wt.rec.archived)
3845
- return true;
3846
- const resumed = await resumeSession(id);
3847
- if (!resumed.ok)
3848
- throw new ResourceConflict(resumed.error || `refusing to resume ${id}`);
3849
- return true;
3850
- }
3851
- // @@@ cold retirement - archive already returned the target's runtime, so closing a proven-cold row must not
3852
- // re-enter the live stop guard and make unrelated shared-root references prove ownership again. Verify only
3853
- // that the target-bound cold proof is still current and that no target PID/window/socket/thread has reappeared.
3854
- // This is read-only: no signal, adapter mutation, or shared-root cleanup belongs on the cold path.
3855
- async function assertColdRetirementSafe(id, rec) {
3856
- if (!rec.archived || !rec.stopped || !hasValidColdProof(rec))
3857
- throw new ResourceConflict(`refusing to close archived session ${id}: target-bound cold witness is missing or stale`);
3858
- if (rec.adapterRecovery)
3859
- throw new ResourceConflict(`refusing to close archived session ${id}: adapter recovery is pending (${rec.adapterRecovery})`);
3860
- const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
3861
- if (snap.probeFailed)
3862
- throw new ResourceConflict(`refusing to close archived session ${id}: liveness probe failed; target runtime absence is unproven`);
3863
- if (snap.windows.has(id))
3864
- throw new ResourceConflict(`refusing to close archived session ${id}: target tmux window has reappeared`);
3865
- if (socket === 'live')
3866
- throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous transport has reappeared`);
3867
- if (socket === 'unproven')
3868
- throw new ResourceConflict(`refusing to close archived session ${id}: target rendezvous state is ambiguous`);
3869
- const leaf = await inspectSessionLeafIdentity(id, rec);
3870
- if (leaf.state === 'owned')
3871
- throw new ResourceConflict(`refusing to close archived session ${id}: target leaf PID ${leaf.identity.pid} is live or recycled; ownership is ambiguous`);
3872
- if (leaf.state === 'unknown')
3873
- throw new ResourceConflict(`refusing to close archived session ${id}: ${leaf.reason}; ownership is ambiguous`);
3874
- const harness = harnessById(rec.harness || defaultHarness.id);
3875
- if (harness.coldRetirementPreflight) {
3876
- const proof = await harness.coldRetirementPreflight(rec);
3877
- if (!proof.ok)
3878
- throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`);
3879
- return;
3880
- }
3881
- const descriptors = harness.sharedRuntimes?.(runtimeRoot()) ?? [];
3882
- let everySharedRootAbsent = descriptors.length > 0;
3883
- for (const descriptor of descriptors) {
3884
- const resident = descriptor.residency
3885
- ? await descriptor.residency()
3886
- : await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }));
3887
- if (!resident.healthy)
3888
- throw new ResourceConflict(`refusing to close archived session ${id}: ${resident.error || `${descriptor.label} resident census is unhealthy`}`);
3889
- if (rec.harnessSessionId && resident.referenceIds.includes(rec.harnessSessionId))
3890
- throw new ResourceConflict(`refusing to close archived session ${id}: target adapter thread ${rec.harnessSessionId} is loaded`);
3891
- everySharedRootAbsent = everySharedRootAbsent && resident.rootAbsent === true;
3892
- }
3893
- if (harness.coldPreflight && !everySharedRootAbsent) {
3894
- const proof = await harness.coldPreflight(rec);
3895
- if (!proof.ok)
3896
- throw new ResourceConflict(`refusing to close archived session ${id}: ${proof.reason}`);
3897
- if (!proof.alreadyCold)
3898
- throw new ResourceConflict(`refusing to close archived session ${id}: target adapter collection is not proven cold`);
3899
- }
3900
- }
3901
- async function assertDiscardableWorktree(id, path, branch, kind) {
3902
- if (existsSync(path)) {
3903
- const status = await gitTry(['-C', path, 'status', '--porcelain', '--untracked-files=all']);
3904
- if (!status.ok)
3905
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree status is unreadable`);
3906
- if (status.stdout.trim())
3907
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree has dirty work`);
3908
- }
3909
- if (branch) {
3910
- const resolved = await gitTry(['-C', mainRoot(), 'rev-parse', '--verify', `${branch}^{commit}`]);
3911
- if (resolved.ok) {
3912
- const count = await gitTry(['-C', mainRoot(), 'rev-list', '--count', `${mainBranch()}..${branch}`]);
3913
- if (!count.ok)
3914
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch ancestry is unreadable`);
3915
- const ahead = Number(count.stdout.trim());
3916
- if (!Number.isFinite(ahead) || ahead !== 0)
3917
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch is ${Number.isFinite(ahead) ? ahead : 'an unknown number of'} commit(s) ahead`);
3918
- }
3919
- else if (resolved.failure !== 'exit') {
3920
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} branch identity is unreadable`);
3921
- }
3922
- else if (existsSync(path)) {
3923
- throw new ResourceConflict(`refusing to close ${kind} session ${id}: ${kind} worktree exists but branch ${branch} is missing`);
3924
- }
3925
- }
3926
- }
3927
4597
  // A never-launched queue owns only prepared disk state. The transition/record locks around close serialize
3928
4598
  // this check with startQueued: whichever wins decides whether the record is still a queue or has become live.
3929
4599
  // No shared-runtime probe belongs here because a valid prepared row has no adapter thread to look up.
3930
- async function assertQueuedRetirementSafe(id, rec, path, branch) {
4600
+ async function assertQueuedCloseSafe(id, rec) {
3931
4601
  if (rec.status !== 'queued' || rec.harnessSessionId)
3932
4602
  throw new ResourceConflict(`refusing to close queued session ${id}: the record has a target thread or is no longer queued`);
3933
4603
  if (rec.adapterRecovery || launching.has(id))
@@ -3946,14 +4616,20 @@ async function assertQueuedRetirementSafe(id, rec, path, branch) {
3946
4616
  const pid = readAgentPid(pidPath);
3947
4617
  throw new ResourceConflict(`refusing to close queued session ${id}: target leaf PID artifact ${Number.isFinite(pid) && pid > 0 ? pid : 'is unreadable'}; never-launched ownership is ambiguous`);
3948
4618
  }
3949
- await assertDiscardableWorktree(id, path, branch, 'prepared');
4619
+ // close preserves the prepared tree's dirty state in refs/spex-archive/<id>; cleanliness is not a
4620
+ // precondition for the soft terminal transition.
3950
4621
  }
3951
4622
  // A launch may leave its row active before Codex publishes the native thread binding. This close path owns
3952
4623
  // only the record's dead local launch residue; an unbound app-server peer stays unowned and untouched.
3953
- async function assertUnboundRetirementSafe(id, rec, path, branch) {
4624
+ async function assertUnboundCloseSafe(id, rec) {
3954
4625
  if (harnessById(rec.harness || defaultHarness.id).exactNativeTargetId(rec) || rec.status === 'queued' || rec.archived)
3955
4626
  throw new ResourceConflict(`refusing to close unbound session ${id}: it is not an unbound live-record residue`);
3956
- if (rec.adapterRecovery || rec.launchReadinessPending || launching.has(id) || existsSync(sessionArtifactPath(id, 'launch')))
4627
+ const failureStamped = /^queued launch readiness failed:/.test(rec.note || '') || rec.status === 'error' || rec.stopped;
4628
+ const readinessStartedAt = rec.launchReadinessStartedAt ?? rec.launchReadinessPending?.startedAt ?? null;
4629
+ const readinessInProgress = !failureStamped && (readinessStartedAt != null
4630
+ ? Date.now() - readinessStartedAt < SOCKET_READY_TIMEOUT_MS
4631
+ : launching.has(id));
4632
+ if (rec.adapterRecovery || readinessInProgress)
3957
4633
  throw new ResourceConflict(`refusing to close unbound session ${id}: launch or recovery is still in progress`);
3958
4634
  const [snap, socket] = await Promise.all([liveSnapshot(id), rendezvousListening(id)]);
3959
4635
  if (snap.probeFailed)
@@ -3968,55 +4644,45 @@ async function assertUnboundRetirementSafe(id, rec, path, branch) {
3968
4644
  const leaf = await inspectSessionLeafIdentity(id, rec);
3969
4645
  if (leaf.state !== 'missing' && leaf.state !== 'dead')
3970
4646
  throw new ResourceConflict(`refusing to close unbound session ${id}: ${leaf.state === 'unknown' ? leaf.reason : 'target leaf identity is live or ambiguous'}`);
3971
- await assertDiscardableWorktree(id, path, branch, 'unbound');
3972
4647
  }
3973
- async function closeOwnedSessionUnlocked(id, wt, source, unboundRetired = false) {
4648
+ async function closeOwnedSessionUnlocked(id, wt, _source, unboundStopped = false) {
3974
4649
  const root = mainRoot();
3975
4650
  const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root);
3976
4651
  if (receiptFailure)
3977
- throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`);
3978
- const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId;
3979
- const retired = !wt.rec.archived && !!retirementReason(wt.rec);
3980
- // A retired row has already lost its worktree; close is its explicit record-only terminal cleanup.
3981
- if (!retired) {
3982
- if (wt.rec.archived)
3983
- await assertColdRetirementSafe(id, wt.rec);
3984
- else if (wt.rec.status === 'queued')
3985
- await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch);
3986
- else if (!unboundRetired)
3987
- throw new ResourceConflict(`refusing to close ${id}: target runtime was not cold-retired first`);
3988
- }
3989
- // The marker protects only the destructive half. A failed cold proof must leave a normal, resumable binding.
3990
- if (closesCodexBinding)
3991
- prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId);
3992
- appendCloseLedger(id, wt.rec, source);
4652
+ throw new ResourceConflict(`refusing close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`);
4653
+ const retired = !!retirementReason(wt.rec);
4654
+ if (!retired && wt.rec.status === 'queued')
4655
+ await assertQueuedCloseSafe(id, wt.rec);
4656
+ if (!retired && !unboundStopped && wt.rec.status !== 'queued') {
4657
+ await coldStopSessionUnlocked(id);
4658
+ wt = (await findWorktree(id)) || wt;
4659
+ }
4660
+ // The archive ref is published before any worktree removal. A failed ref write leaves the complete
4661
+ // worktree and record in place for retry.
4662
+ if (!retired && existsSync(wt.path))
4663
+ archiveWorktreeState(id, wt.path);
4664
+ const latest = readRecord(id);
4665
+ if (!latest)
4666
+ throw new ResourceConflict(`refusing to finish close for ${id}: session record disappeared before publication`);
4667
+ writeRecord({
4668
+ ...latest,
4669
+ archived: true,
4670
+ closedAt: latest.closedAt || new Date().toISOString(),
4671
+ stopped: true,
4672
+ coldProof: latest.coldProof || coldProofFor(latest),
4673
+ adapterRecovery: null,
4674
+ });
3993
4675
  let slot = null;
3994
4676
  try {
3995
- slot = treeSlotDir(wt.path);
4677
+ slot = existsSync(wt.path) ? treeSlotDir(wt.path) : null;
3996
4678
  }
3997
4679
  catch { /* tree already unresolvable — nothing to key the slot by */ }
3998
- // a retired session's worktree/branch are already gone; removing them is a no-op to skip, not a failure.
3999
4680
  if (existsSync(wt.path)) {
4000
- const removed = await gitTry(['-C', root, 'worktree', 'remove', '--force', wt.path]);
4001
- if (!removed.ok)
4002
- throw new ResourceConflict(`refusing to finish close for ${id}: worktree removal failed`);
4003
- if (existsSync(wt.path))
4004
- throw new ResourceConflict(`refusing to finish close for ${id}: worktree remains after removal`);
4005
- }
4006
- if (wt.branch) {
4007
- const branchRef = `refs/heads/${wt.branch}`;
4008
- const present = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef]);
4009
- if (present.ok) {
4010
- const removed = await gitTry(['-C', root, 'branch', '-D', wt.branch]);
4011
- if (!removed.ok)
4012
- throw new ResourceConflict(`refusing to finish close for ${id}: branch removal failed`);
4013
- const remaining = await gitTry(['-C', root, 'rev-parse', '--verify', '--quiet', branchRef]);
4014
- if (remaining.ok || remaining.failure !== 'exit')
4015
- throw new ResourceConflict(`refusing to finish close for ${id}: branch remains or its removal is unproven`);
4016
- }
4017
- else if (present.failure !== 'exit') {
4018
- throw new ResourceConflict(`refusing to finish close for ${id}: branch presence is unreadable`);
4019
- }
4681
+ const trashed = moveWorktreeToTrash(root, wt.path);
4682
+ const pruned = await gitTry(['-C', root, 'worktree', 'prune']);
4683
+ if (!pruned.ok)
4684
+ console.error(`spex: deferred worktree ${id} was renamed to ${trashed}, but git worktree prune failed: ${pruned.stderr.trim() || pruned.failure}`);
4685
+ queueWorktreeTrash(trashed);
4020
4686
  }
4021
4687
  if (slot) {
4022
4688
  try {
@@ -4024,20 +4690,6 @@ async function closeOwnedSessionUnlocked(id, wt, source, unboundRetired = false)
4024
4690
  }
4025
4691
  catch { /* best-effort GC */ }
4026
4692
  }
4027
- try {
4028
- rmSync(sessionStoreDir(id), { recursive: true, force: true });
4029
- }
4030
- catch (error) {
4031
- throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`);
4032
- }
4033
- if (existsSync(sessionStoreDir(id)))
4034
- throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`);
4035
- // The close still owns this sender's record lock. Marking after its store is gone lets any send already
4036
- // admitted finish before close returns, while every later send and every retry sweep sees terminal output.
4037
- revokeSenderDelivery(id);
4038
- if (closesCodexBinding && wt.rec.harnessSessionId) {
4039
- bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null);
4040
- }
4041
4693
  requestQueueDrain(); // a close frees a slot — start the next queued session if any
4042
4694
  return true;
4043
4695
  }
@@ -4053,7 +4705,7 @@ async function closeSessionUnlocked(id, source) {
4053
4705
  const runtime = sessionStoreDir(id);
4054
4706
  const evidence = quarantined
4055
4707
  ? `Original bytes were copied to ${quarantined}`
4056
- : `Original bytes remain at ${join(runtime, 'session.json')}; no quarantine copy could be made`;
4708
+ : `Original bytes remain at ${join(runtime, 'runtime.json')}; no quarantine copy could be made`;
4057
4709
  let guard = 'no readable session record proves the adapter or leaf owner';
4058
4710
  try {
4059
4711
  await stopAgentProcess(id, null);
@@ -4061,42 +4713,29 @@ async function closeSessionUnlocked(id, source) {
4061
4713
  catch (error) {
4062
4714
  guard = error instanceof Error ? error.message : String(error);
4063
4715
  }
4064
- throw new SessionRecordUnusable('corrupt', id, `refusing destructive close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`);
4716
+ throw new SessionRecordUnusable('corrupt', id, `refusing close for ${id}: the unreadable record proves no adapter, leaf, worktree, or branch owner (${guard}). ${evidence}. Runtime remains at ${runtime}; worktree and branch ownership is unknown and was not touched; no process signal or deletion was attempted.`);
4065
4717
  }
4066
4718
  if (!wt)
4067
4719
  return false;
4068
- let unboundRetired = false;
4720
+ let unboundStopped = false;
4069
4721
  if (!retirementReason(wt.rec) && !wt.rec.archived && wt.rec.status !== 'queued') {
4070
4722
  const harness = harnessById(wt.rec.harness || defaultHarness.id);
4071
4723
  if (!harness.exactNativeTargetId(wt.rec)) {
4072
- await assertUnboundRetirementSafe(id, wt.rec, wt.path, wt.branch);
4724
+ await assertUnboundCloseSafe(id, wt.rec);
4073
4725
  await tmuxOk(['kill-session', '-t', id]);
4074
- await assertTargetTmuxAbsent(id, 'after unbound residue retirement');
4726
+ await assertTargetTmuxAbsent(id, 'after unbound residue close');
4075
4727
  await harness.cleanupRuntime(wt.rec);
4076
- unboundRetired = true;
4728
+ unboundStopped = true;
4077
4729
  }
4078
4730
  else {
4079
- // A confirmed terminal close may end an exact native turn before cold proof. Ordinary archive deliberately
4080
- // remains non-destructive while a turn is active; close already means discard this session's work.
4081
- assertSessionOwnerSafe(id, harness.id);
4082
- const interrupt = harness.interrupt;
4083
- if (interrupt) {
4084
- const result = await interrupt({ ...wt.rec, runtimeDir: runtimeRoot() });
4085
- if (!result.ok)
4086
- throw new ResourceConflict(`refusing to close ${id}: native interrupt failed (${result.error || 'unknown error'})`);
4087
- }
4088
- const archived = await archiveSessionUnlocked(id);
4089
- if (!archived)
4090
- return false;
4091
- wt = await findWorktree(id);
4092
- if (!wt)
4093
- return false;
4731
+ // closeOwnedSessionUnlocked performs the one exact cold-stop proof immediately before archive-ref
4732
+ // publication. Keeping that seam in one place prevents a second adapter mutation on retry.
4094
4733
  }
4095
4734
  }
4096
4735
  const target = wt;
4097
4736
  return target.branch
4098
- ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source, unboundRetired))
4099
- : closeOwnedSessionUnlocked(id, target, source, unboundRetired);
4737
+ ? withRecordLock(sessionCandidateLockId(target.path, target.branch), () => closeOwnedSessionUnlocked(id, target, source, unboundStopped))
4738
+ : closeOwnedSessionUnlocked(id, target, source, unboundStopped);
4100
4739
  }
4101
4740
  export const closeSession = (id, rawSource) => {
4102
4741
  const source = normalizeCloseSource(rawSource);
@@ -4229,7 +4868,7 @@ export async function quarantineCorruptRecord(id, rawWitness) {
4229
4868
  const git = await proveQuarantineGitAbsent(id, witness);
4230
4869
  const adapter = await proveQuarantineAdapter(id, witness);
4231
4870
  const bundle = join(quarantineRoot(id), `${observedAt.replace(/[:.]/g, '-')}-${randomUUID()}`);
4232
- const stored = join(bundle, 'session.json');
4871
+ const stored = join(bundle, 'runtime.json');
4233
4872
  const provenance = join(bundle, 'provenance.json');
4234
4873
  const audit = {
4235
4874
  version: 1,
@@ -4279,10 +4918,10 @@ export async function restoreQuarantinedRecord(id) {
4279
4918
  throw new ResourceConflict(`refusing to restore ${id}: no quarantine bundle exists`);
4280
4919
  throw new ResourceConflict(`refusing to restore ${id}: quarantine bundle inventory is unreadable`);
4281
4920
  }
4282
- const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'session.json')) && existsSync(join(path, 'provenance.json')));
4921
+ const bundle = bundles.map((name) => join(quarantineRoot(id), name)).find((path) => existsSync(join(path, 'runtime.json')) && existsSync(join(path, 'provenance.json')));
4283
4922
  if (!bundle)
4284
4923
  throw new ResourceConflict(`refusing to restore ${id}: no complete quarantine bundle exists`);
4285
- const stored = join(bundle, 'session.json');
4924
+ const stored = join(bundle, 'runtime.json');
4286
4925
  let provenance;
4287
4926
  try {
4288
4927
  provenance = JSON.parse(readFileSync(join(bundle, 'provenance.json'), 'utf8'));
@@ -4486,6 +5125,7 @@ export function formatTable(sessions, color = true, scope = { kind: 'sessions' }
4486
5125
  });
4487
5126
  return [heading, header, ...rows, statusLegend(color)].join('\n');
4488
5127
  }
5128
+ export const EMPTY_PROMPT_ERROR = 'empty prompt — nothing to dispatch';
4489
5129
  class StrandedDeliveryError extends Error {
4490
5130
  }
4491
5131
  async function strandedDeliveryError(rec) {
@@ -4495,88 +5135,166 @@ async function strandedDeliveryError(rec) {
4495
5135
  const transport = await h.deliveryTransport({ ...rec, runtimeDir: runtimeRoot() });
4496
5136
  if (transport.kind !== 'unreachable' || agentAlive(rec.session) !== true)
4497
5137
  return null;
4498
- const queued = pendingMessages(rec.session).length;
5138
+ const application = configuredSessionApplicationIfCutover();
5139
+ const queued = application?.readPendingMessages(rec.session).length ?? 0;
4499
5140
  const noun = queued === 1 ? 'message is' : 'messages are';
4500
5141
  return new StrandedDeliveryError(`session ${rec.session} is stranded: ${transport.reason} while its registered agent process is still alive; ${queued} queued ${noun} waiting with no transport to claim them. Use \`spex session send ${rec.session} --keys "<keys>"\` to steer the live tmux pane, then repair the control transport before sending text.`);
4501
5142
  }
4502
5143
  export async function sendText(id, text, from, opts = {}) {
4503
- if (!text)
4504
- return { ok: false, error: 'empty prompt — nothing to dispatch' };
4505
- let replayed = false;
4506
- try {
4507
- // Taking a declared sender's record lock makes close a real outgoing fence even across backend processes:
4508
- // a send either appends before close obtains the fence (and close's revocation voids its debt), or sees
4509
- // the terminal marker before it records anything. Arbitrary legacy `from` values keep working; they just
4510
- // name an otherwise-unused lock until a matching session is closed.
4511
- let rec = null;
4512
- const accepted = await acceptMessage({
4513
- target: id,
4514
- text,
4515
- from,
4516
- idempotency: opts.idempotency,
4517
- validate: async () => {
4518
- rec = readRecord(id);
4519
- if (!rec)
4520
- throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
4521
- },
4522
- prepare: async () => {
4523
- await opts.acceptGuard?.(rec);
4524
- const stranded = await strandedDeliveryError(rec);
4525
- if (stranded)
4526
- throw stranded;
4527
- // Composed at ACCEPT time, once: the log keeps the raw conversational text plus the effective reply channel,
4528
- // the queue keeps the transport form. Composing again at handover would let a later send change the hints on
4529
- // a message that was already accepted.
4530
- const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia });
4531
- return { text: prompt.text, ...(prompt.replyVia ? { replyVia: prompt.replyVia } : {}) };
4532
- },
4533
- });
4534
- replayed = accepted.replayed;
4535
- }
4536
- catch (error) {
4537
- const code = error?.code;
4538
- const detail = error instanceof StrandedDeliveryError
4539
- ? error.message
4540
- : `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered`;
5144
+ if (!text.trim())
5145
+ return { ok: false, error: EMPTY_PROMPT_ERROR };
5146
+ const application = configuredSessionApplicationIfCutover();
5147
+ if (application) {
5148
+ let message;
5149
+ let replayed = false;
5150
+ try {
5151
+ const rec = readRecord(id);
5152
+ if (!rec)
5153
+ throw new ResourceConflict(`no session record for ${id} — prompt NOT delivered`);
5154
+ await opts.acceptGuard?.(rec);
5155
+ const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia });
5156
+ const idempotencyKey = opts.idempotency?.requestDigest ?? (opts.deliveryKey?.trim() || null);
5157
+ const existing = idempotencyKey
5158
+ ? application.readMessageHistory(id).find(message => message.idempotencyKey === idempotencyKey)
5159
+ : undefined;
5160
+ message = existing ?? application.enqueueConversationMessage(id, {
5161
+ kind: 'session.prompt.v1',
5162
+ body: Buffer.from(prompt.text, 'utf8'),
5163
+ senderSessionId: from ?? null,
5164
+ idempotencyKey,
5165
+ }, { text, from: from ?? null, ...(prompt.replyVia ? { replyVia: prompt.replyVia } : {}) });
5166
+ replayed = !!existing;
5167
+ }
5168
+ catch (error) {
5169
+ return { ok: false, error: `could not append the message to session ${id}'s application queue: ${error instanceof Error ? error.message : String(error)}` };
5170
+ }
5171
+ // Acceptance and handover are separate boundaries. A committed SQLite message remains a successful
5172
+ // command even when the runtime is currently unbound; binding/resume is the explicit event that makes
5173
+ // the durable debt drainable. Reporting the post-commit refusal as an append failure made command-box
5174
+ // callers show a false error despite the prompt already being safely queued.
5175
+ if (!opts.deferDrain) {
5176
+ try {
5177
+ await drainSession(id);
5178
+ }
5179
+ catch (error) {
5180
+ if (!(error instanceof ResourceConflict) || !/no bound spex-governed runtime/u.test(error.message))
5181
+ throw error;
5182
+ }
5183
+ }
5184
+ const pending = application.readPendingMessages(id).some(candidate => candidate.messageId === message.messageId);
5185
+ // Queue acceptance is not runtime activity. A prompt remains owed while the adapter is unbound,
5186
+ // restarting, or refusing the insert; only the handoff that removes this exact message may re-enter
5187
+ // a waiting session as active. This keeps a queued command from painting a dead pane as working.
5188
+ if (!from && !pending)
5189
+ markHumanPromptActive(id);
5190
+ // @@@ a deferred drain measured NOTHING, and must not be reported as a refusal.
5191
+ // `pending` is read microseconds after the enqueue with no await in between, so when the drain was
5192
+ // deferred it is answering "did I skip the handover" — always yes — rather than "did the adapter refuse".
5193
+ // Reporting that as `queued` made every first Command Box send claim the transport was still owed while
5194
+ // the prompt was in fact in the agent's pane milliseconds later, and the claim was UNCONDITIONAL: no
5195
+ // transport state, harness, or runtime binding could change it. Name the deferral instead, so the one
5196
+ // caller that defers can say "accepted, handover in flight" and the callers that DO drain keep a
5197
+ // `queued` that still means what it says.
4541
5198
  return {
4542
- ok: false,
4543
- error: detail,
4544
- ...(code ? { code } : {}),
5199
+ ok: true,
5200
+ delivery: opts.deferDrain ? 'deferred' : pending ? 'queued' : 'accepted',
5201
+ ...(opts.idempotency || opts.deliveryKey ? { replayed } : {}),
4545
5202
  };
4546
5203
  }
4547
- // Awaited, not fire-and-forget: an unawaited insert can lose its race with a short-lived caller's exit,
4548
- // costing that send its same-turn arrival. Draining HERE rather than leaving it to the sweep is what puts
4549
- // the text in a live agent's current turn instead of up to one tick later.
4550
- if (!opts.deferDrain)
4551
- await drainSession(id);
4552
- return { ok: true, ...(opts.idempotency ? { replayed } : {}) };
5204
+ throw new ResourceConflict('session application is unavailable; refusing the legacy delivery path');
4553
5205
  }
4554
5206
  // @@@ drainSession - hand over what this session is owed, as ordinary prompts. Safe to call from anywhere and
4555
5207
  // at any time: the queue's own lock serializes concurrent passes, and an empty queue costs one existsSync.
4556
5208
  // The retry sweep in `serve` calls this for the sessions whose queues an earlier pass could not empty.
4557
5209
  export async function drainSession(id) {
4558
- if (!owesDelivery(id))
4559
- return;
4560
- const rec = readRecord(id);
4561
- if (!rec)
4562
- return;
4563
- const h = harnessById(rec.harness || defaultHarness.id);
4564
- if (h.launchPayloadProof && !rec.harnessSessionId)
4565
- return;
4566
- await drain(id, async (msg) => {
4567
- // the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
4568
- // prompt its channel confirms (claude's sessions panel), checkable only from the pane. Treated as a REFUSAL
4569
- // rather than a skip the message stays owed and the sweep hands it over once the pane leaves that state.
4570
- if (h.deliveryBlockedBy) {
4571
- try {
4572
- if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
4573
- return false;
5210
+ const application = configuredSessionApplicationIfCutover();
5211
+ if (application) {
5212
+ const rec = readRecord(id);
5213
+ if (!rec)
5214
+ return;
5215
+ // An empty canonical queue is a successful no-op. Do not turn a resume with no owed prompt into a
5216
+ // runtime-binding error; require a bound adapter only when there is a message that must be handed over.
5217
+ if (application.readPendingMessages(id).length === 0)
5218
+ return;
5219
+ const h = harnessById(rec.harness || defaultHarness.id);
5220
+ const binding = application.resolveRuntime(id, 'spex-governed');
5221
+ if (!binding || binding.status !== 'bound') {
5222
+ // Leaf adapters own their per-session controller and can deliver without a shared native identity.
5223
+ // Preserve the governed transport while that identity is absent, then acknowledge the same canonical
5224
+ // queue directly. Shared adapter runtimes (Codex) remain fail-closed until their exact binding exists.
5225
+ const leafWithoutNativeIdentity = !rec.harnessSessionId && (rec.harness === 'claude' || h.runtimeOwnership === 'leaf');
5226
+ if (leafWithoutNativeIdentity) {
5227
+ await withDeliveryLocks([id], async () => {
5228
+ for (;;) {
5229
+ const pending = application.readPendingMessages(id);
5230
+ const msg = pending[0];
5231
+ if (!msg)
5232
+ return;
5233
+ const text = canonicalMessageText(msg, rec);
5234
+ if (h.deliveryBlockedBy) {
5235
+ try {
5236
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
5237
+ return;
5238
+ }
5239
+ catch { /* no pane to consult — let the adapter decide */ }
5240
+ }
5241
+ const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.messageId }, text);
5242
+ if (!delivered.ok)
5243
+ return;
5244
+ const removed = application.dequeuePendingMessage(id, msg.messageId);
5245
+ if (!removed || removed.messageId !== msg.messageId)
5246
+ throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
5247
+ }
5248
+ });
5249
+ return;
4574
5250
  }
4575
- catch { /* no pane to consult let the insert itself decide */ }
5251
+ throw new ResourceConflict(`canonical delivery for ${id} remains pending: no bound spex-governed runtime`);
4576
5252
  }
4577
- const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.mid }, msg.text);
4578
- return delivered.ok;
4579
- });
5253
+ await withDeliveryLocks([id], async () => {
5254
+ for (;;) {
5255
+ const pending = application.readPendingMessages(id);
5256
+ const msg = pending[0];
5257
+ if (!msg)
5258
+ return;
5259
+ const text = canonicalMessageText(msg, rec);
5260
+ if (h.deliveryBlockedBy) {
5261
+ try {
5262
+ if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS)))
5263
+ return;
5264
+ }
5265
+ catch { /* no pane to consult — let the adapter decide */ }
5266
+ }
5267
+ const delivered = await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid: msg.messageId }, text);
5268
+ if (!delivered.ok)
5269
+ return;
5270
+ const removed = application.dequeueForRuntime(id, 'spex-governed', binding.bindingGeneration, msg.messageId);
5271
+ if (!removed || removed.messageId !== msg.messageId)
5272
+ throw new ResourceConflict(`canonical queue head changed while delivering ${id}`);
5273
+ }
5274
+ });
5275
+ return;
5276
+ }
5277
+ throw new ResourceConflict('session application is unavailable; refusing the legacy delivery path');
5278
+ }
5279
+ // `recipient` is the session this text is delivered TO; a state message speaks about its `sessionId`, the
5280
+ // watched subject, and the notice must name that subject — never the reader of the notice.
5281
+ export function canonicalMessageText(message, recipient) {
5282
+ if (message.kind === 'session.prompt.v1')
5283
+ return Buffer.from(message.body).toString('utf8');
5284
+ if (message.kind === 'session.state.changed.v1') {
5285
+ try {
5286
+ const change = JSON.parse(Buffer.from(message.body).toString('utf8'));
5287
+ if (typeof change.sessionId !== 'string' || !change.sessionId)
5288
+ throw new ResourceConflict(`canonical state message delivered to ${recipient.session} names no subject session`);
5289
+ return watchMessage({ ...recipient, session: change.sessionId, status: (change.status ?? recipient.status), proposal: change.proposal ?? null, note: change.note ?? null, parent: change.parentSessionId ?? null });
5290
+ }
5291
+ catch (error) {
5292
+ if (error instanceof ResourceConflict)
5293
+ throw error;
5294
+ throw new ResourceConflict(`canonical state message for ${recipient.session} is not valid JSON`);
5295
+ }
5296
+ }
5297
+ throw new ResourceConflict(`canonical message kind ${message.kind} cannot be delivered as session text`);
4580
5298
  }
4581
5299
  // Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
4582
5300
  // confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
@@ -4650,7 +5368,7 @@ function rawKeyArgs(id, key) {
4650
5368
  // (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
4651
5369
  // An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
4652
5370
  export async function rawKey(id, key) {
4653
- return withRecordLock(id, async () => {
5371
+ const sent = await withRecordLock(id, async () => {
4654
5372
  const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0);
4655
5373
  if (list.length === 0 || !(await alive(id)))
4656
5374
  return false;
@@ -4664,4 +5382,7 @@ export async function rawKey(id, key) {
4664
5382
  }
4665
5383
  return sent;
4666
5384
  });
5385
+ // Raw-key remote control is transport fallback, not a lifecycle event. Freshness belongs to the
5386
+ // harness turn hooks or a successfully handed-over durable prompt; navigation keys cannot forge working.
5387
+ return sent;
4667
5388
  }