spexcode 0.6.5 → 0.6.6

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 (56) hide show
  1. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.d.ts +1 -1
  2. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.js +18 -2
  3. package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
  4. package/node_modules/@spexcode/session-core/dist/index.js +5 -0
  5. package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
  6. package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
  7. package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
  8. package/node_modules/@spexcode/session-core/dist/message.js +52 -0
  9. package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
  10. package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
  11. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +50 -0
  12. package/node_modules/@spexcode/session-core/dist/runtime-session.js +286 -0
  13. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +46 -0
  14. package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
  15. package/node_modules/@spexcode/session-core/package.json +33 -0
  16. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +2 -1
  17. package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +4 -1
  18. package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +13 -4
  19. package/node_modules/@spexcode/spec-cli/dist/cli.js +72 -23
  20. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +2 -1
  21. package/node_modules/@spexcode/spec-cli/dist/client.js +13 -8
  22. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +5 -0
  23. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +112 -0
  24. package/node_modules/@spexcode/spec-cli/dist/doctor.js +7 -1
  25. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +7 -5
  26. package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +1 -0
  27. package/node_modules/@spexcode/spec-cli/dist/gateway.js +44 -20
  28. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +2 -1
  29. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +2 -1
  30. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -2
  31. package/node_modules/@spexcode/spec-cli/dist/harness.js +174 -54
  32. package/node_modules/@spexcode/spec-cli/dist/help.d.ts +5 -0
  33. package/node_modules/@spexcode/spec-cli/dist/help.js +26 -6
  34. package/node_modules/@spexcode/spec-cli/dist/index.js +3 -3
  35. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +2 -1
  36. package/node_modules/@spexcode/spec-cli/dist/listen.js +10 -10
  37. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +1 -0
  38. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +7 -0
  39. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.d.ts +1 -0
  40. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -0
  41. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +1 -1
  42. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +6 -46
  43. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +8 -221
  44. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +25 -4
  45. package/node_modules/@spexcode/spec-cli/dist/sessions.js +822 -394
  46. package/node_modules/@spexcode/spec-cli/dist/supervise.js +3 -3
  47. package/node_modules/@spexcode/spec-cli/package.json +6 -4
  48. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  49. package/node_modules/@spexcode/spec-core/dist/git.js +32 -0
  50. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +4 -0
  51. package/node_modules/@spexcode/spec-core/package.json +1 -1
  52. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  53. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  54. package/package.json +3 -3
  55. /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.d.ts +0 -0
  56. /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.js +0 -0
@@ -1,4 +1,4 @@
1
- import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
1
+ import { closeSync, openSync, readSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
2
2
  import { join, dirname, basename } from 'node:path';
3
3
  import { homedir, tmpdir } from 'node:os';
4
4
  import { createHash, randomBytes } from 'node:crypto';
@@ -9,11 +9,11 @@ import { fileURLToPath } from 'node:url';
9
9
  import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlashCommands } from './slash-commands.js';
10
10
  import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js';
11
11
  import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js';
12
- import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
12
+ import { claudeHeadlessColdRuntime, claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
13
13
  import { codexHeadlessLaunchCommand } from './codex-headless.js';
14
- import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
14
+ import { opencodeHeadlessColdRuntime, opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
15
15
  import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
16
- import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from '@spexcode/spec-core';
16
+ import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHome } from '@spexcode/spec-core';
17
17
  import { git } from '@spexcode/spec-core';
18
18
  import { shQuote } from './sh.js';
19
19
  import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
@@ -67,9 +67,9 @@ export async function adapterLoadedReferenceState(records, runtimeDir = runtimeR
67
67
  // sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
68
68
  // that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
69
69
  // `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
70
- // and Enters can't be corrupted the way `tmux send-keys` was. It lives in tmpdir tied to the claude process, so
71
- // no extra lifecycle. liveness CONNECTS to it (a live LISTENER, not merely the file — see rendezvousListening);
72
- // deliver writes to it.
70
+ // and Enters can't be corrupted the way `tmux send-keys` was. It lives in SpexCode's own durable store, and
71
+ // its launch-time stamp still gives it no independent lifecycle. liveness CONNECTS to it (a live LISTENER, not
72
+ // merely the file — see rendezvousListening); deliver writes to it.
73
73
  //
74
74
  // The path is a LAUNCH-TIME FACT, recorded — not a formula every consumer re-derives. The id alone was not
75
75
  // enough to name it: `SPEXCODE_HOME` scopes the store and `SPEXCODE_TMUX` scopes the tmux server, so two
@@ -83,18 +83,22 @@ export async function adapterLoadedReferenceState(records, runtimeDir = runtimeR
83
83
  // `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
84
84
  // the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
85
85
  export const legacyRvSock = (id) => join(tmpdir(), `spexcode-rv-${id}.sock`);
86
- // @@@ rendezvousSocketBase - macOS's TMPDIR commonly expands to /var/folders/<long-user-path>, leaving a
87
- // UUID-scoped socket beyond its 104-byte sun_path limit. It can be created and observed by pathname but every
88
- // connect fails EINVAL, which looks exactly like an unresponsive worker. Use a literal short /tmp spelling
89
- // (not tmpdir(), whose resolved form is long on macOS) with the same private per-uid directory pattern as the
90
- // Codex runtime socket. Existing launches retain their stamped old path through rvSock(); only new launches use
91
- // this base.
92
- const rendezvousSocketBase = () => {
93
- const base = join('/tmp', `spexcode-rv-${process.getuid?.() ?? 0}`);
94
- mkdirSync(base, { recursive: true, mode: 0o700 });
95
- return base;
96
- };
97
- export const scopedRvSock = (id, dir = runtimeRoot()) => join(rendezvousSocketBase(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`);
86
+ // @@@ scoped rendezvous path - a rendezvous endpoint IS this session's address, so it needs a durable home and
87
+ // a bounded name. `<SPEXCODE_HOME>/s/<16hex>/c` is both: the store is SpexCode-owned (normally ~/.spexcode),
88
+ // while one fixed digest of runtime scope + session identity gives every world/session its own short directory
89
+ // without carrying either raw value in the pathname. This is deliberately NOT the Codex app-server rule: that
90
+ // endpoint routes project-shared threads; this one names a session-owned listener. Existing launches retain
91
+ // their stamped old path through rvSock(); only a new stamp uses this derivation.
92
+ const RENDEZVOUS_SUN_PATH_LIMIT = 104;
93
+ export const scopedRvSock = (id, dir = runtimeRoot()) => join(spexcodeHome(), 's', createHash('sha1').update(`${dir}\0${id}`).digest('hex').slice(0, 16), 'c');
94
+ export function assertRvSockPath(id, dir = runtimeRoot()) {
95
+ const path = scopedRvSock(id, dir);
96
+ const bytes = Buffer.byteLength(path);
97
+ if (bytes >= RENDEZVOUS_SUN_PATH_LIMIT) {
98
+ throw new Error(`rendezvous socket path is ${bytes} bytes (must be < ${RENDEZVOUS_SUN_PATH_LIMIT}): ${path}; shorten SPEXCODE_HOME before creating this session`);
99
+ }
100
+ return path;
101
+ }
98
102
  const rvStamp = (id) => sessionArtifactPath(id, 'rv.path');
99
103
  export const rvSock = (id) => {
100
104
  try {
@@ -107,7 +111,8 @@ export const rvSock = (id) => {
107
111
  // launch's half: derive this session's socket in ITS runtime and record it, so every later reader (launch env,
108
112
  // liveness probe, delivery, teardown) reads the one path the agent actually bound.
109
113
  export function stampRvSock(id, dir = runtimeRoot()) {
110
- const path = scopedRvSock(id, dir);
114
+ const path = assertRvSockPath(id, dir);
115
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
111
116
  mkdirSync(dirname(rvStamp(id)), { recursive: true });
112
117
  writeFileSync(rvStamp(id), path);
113
118
  return path;
@@ -140,6 +145,15 @@ export function listenerAt(path, timeoutMs = 800) {
140
145
  });
141
146
  }
142
147
  export const rendezvousListening = (id, timeoutMs = 800) => listenerAt(rvSock(id), timeoutMs);
148
+ const rendezvousDeliveryTransportAt = async (path) => {
149
+ const probe = await listenerAt(path);
150
+ if (probe === 'live')
151
+ return { kind: 'reachable' };
152
+ if (probe === 'unproven')
153
+ return { kind: 'unproven' };
154
+ return { kind: 'unreachable', reason: 'its launch-time rendezvous listener is absent or refusing connections' };
155
+ };
156
+ const rendezvousDeliveryTransport = (rec) => rendezvousDeliveryTransportAt(rvSock(rec.session));
143
157
  // The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
144
158
  // dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
145
159
  // dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
@@ -277,6 +291,17 @@ function claudeForkTransport(sourceSessionId, runtimeDir) {
277
291
  }
278
292
  return null;
279
293
  }
294
+ async function claudeDeliveryTransport(rec) {
295
+ const fork = claudeForkTransport(rec.session, rec.runtimeDir);
296
+ if (!fork)
297
+ return rendezvousDeliveryTransport(rec);
298
+ const forkState = await rendezvousDeliveryTransportAt(fork.sock);
299
+ if (forkState.kind !== 'unreachable' || fork.sock === rvSock(rec.session))
300
+ return forkState;
301
+ // A stale roster row must not strand a source worker whose stamped launch transport is still reachable.
302
+ return rendezvousDeliveryTransport(rec);
303
+ }
304
+ const unprovenDeliveryTransport = async () => ({ kind: 'unproven' });
280
305
  function replyViaSocket(sock, text, mid, auth) {
281
306
  return new Promise((resolve) => {
282
307
  let settled = false;
@@ -505,8 +530,8 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
505
530
  // TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
506
531
  // codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
507
532
  // thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
508
- // does thread/start { cwd = this worktree } on the shared per-project app-server, stores the new id on the
509
- // governed record (SPEXCODE_SESSION_ID), and fires the tail as the FIRST turn, materializing the rollout.
533
+ // does thread/start { cwd = this worktree } on the shared per-project app-server, fires the tail as the
534
+ // FIRST turn, materializes the rollout, and stages the new id + payload proof for the lifecycle owner.
510
535
  // Either way it ends with a thread id, which the visible TUI then RESUMES (the rollout persists on disk),
511
536
  // rendering it natively. A new launch's tail is always ONE single-quoted prompt arg, so it can never be the
512
537
  // literal "--resume" marker — the discriminator is unambiguous. codex-launch only prints an id once its
@@ -1290,8 +1315,15 @@ async function codexColdPreflightOnce(threadId, dir = runtimeRoot(), expectedGen
1290
1315
  const presence = codexPresenceFromStatus(statusById.get(id));
1291
1316
  if (presence === 'active')
1292
1317
  return { ok: false, reason: `Codex subtree member ${id} has an active turn` };
1293
- if (presence === 'unknown')
1294
- return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` };
1318
+ if (presence === 'unknown') {
1319
+ const rollout = codexRolloutTurnSettlement(id);
1320
+ if (!rollout.settled) {
1321
+ return {
1322
+ ok: false,
1323
+ reason: `Codex subtree member ${id} turn state is unknown: live Codex client did not report a determinate turn state; ${rollout.reason}`,
1324
+ };
1325
+ }
1326
+ }
1295
1327
  if (archivedSet.has(id))
1296
1328
  return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` };
1297
1329
  }
@@ -1329,17 +1361,49 @@ async function codexColdPreflight(threadId, dir = runtimeRoot(), expectedGenerat
1329
1361
  }
1330
1362
  throw new Error('unreachable Codex cold preflight retry state');
1331
1363
  }
1364
+ // A corrupt record has no binding we can trust. Locate its one materialized native thread before cold proof;
1365
+ // choosing current or legacy would redirect a destructive operation across a generation boundary.
1366
+ async function codexEndpointForOrphanThread(threadId, dir = runtimeRoot()) {
1367
+ const tracked = codexGenerationEndpoints(dir);
1368
+ const endpoints = tracked.length ? tracked : [legacyCodexGenerationEndpoint(dir)];
1369
+ const scans = await Promise.all(endpoints.map(async (endpoint) => {
1370
+ const generation = codexMutationGeneration(dir, endpoint);
1371
+ if (!generation)
1372
+ return { endpoint, reason: 'Codex shared app-server generation is unproven' };
1373
+ const [active, archived] = await Promise.all([
1374
+ codexThreadList(endpoint.socketPath, { archived: false, sourceKinds: [] }),
1375
+ codexThreadList(endpoint.socketPath, { archived: true, sourceKinds: [] }),
1376
+ ]);
1377
+ if (codexRuntimeGeneration(dir, endpoint) !== generation)
1378
+ return { endpoint, reason: 'Codex shared app-server generation changed during orphan location' };
1379
+ if (!active.ok)
1380
+ return { endpoint, reason: active.error };
1381
+ if (!archived.ok)
1382
+ return { endpoint, reason: archived.error };
1383
+ return { endpoint, generation, containsTarget: active.ids.includes(threadId) || archived.ids.includes(threadId) };
1384
+ }));
1385
+ const failed = scans.find((scan) => 'reason' in scan);
1386
+ if (failed)
1387
+ return { ok: false, reason: `${failed.reason} while locating orphan Codex thread ${threadId} on generation ${failed.endpoint.id}` };
1388
+ const matches = scans.filter((scan) => 'containsTarget' in scan && scan.containsTarget);
1389
+ if (matches.length !== 1) {
1390
+ const detail = matches.length ? matches.map((scan) => scan.endpoint.id).join(', ') : 'none';
1391
+ return { ok: false, reason: `Codex orphan thread ${threadId} has ${matches.length === 0 ? 'no materialized' : 'ambiguous'} generation location (${detail})` };
1392
+ }
1393
+ return { ok: true, endpoint: matches[0].endpoint, generation: matches[0].generation };
1394
+ }
1332
1395
  async function codexQuarantineOrphanThread(threadId, opts) {
1333
1396
  const dir = runtimeRoot();
1334
- const generation = codexMutationGeneration(dir);
1335
- if (!generation)
1336
- return { ok: false, reason: 'Codex shared app-server generation is unproven' };
1337
1397
  const owners = governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId);
1338
1398
  if (owners === null)
1339
1399
  return { ok: false, reason: 'governed Codex thread-owner census is unreadable' };
1340
1400
  if (owners.length)
1341
1401
  return { ok: false, reason: `Codex native thread ${threadId} has governed owner(s) ${owners.join(', ')}` };
1342
- const before = await codexColdPreflight(threadId, dir, generation);
1402
+ const location = await codexEndpointForOrphanThread(threadId, dir);
1403
+ if (!location.ok)
1404
+ return location;
1405
+ const { endpoint, generation } = location;
1406
+ const before = await codexColdPreflight(threadId, dir, generation, endpoint);
1343
1407
  if (!before.ok)
1344
1408
  return before;
1345
1409
  const plan = before.receipt;
@@ -1362,7 +1426,6 @@ async function codexQuarantineOrphanThread(threadId, opts) {
1362
1426
  if (plan.activeIds.length !== 1 || plan.activeIds[0] !== threadId || plan.archivedIds.length)
1363
1427
  return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` };
1364
1428
  const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId);
1365
- const legacy = legacyCodexGenerationEndpoint(dir);
1366
1429
  // Quarantine archives one exact orphan, so it pays the same flush a subtree member does when that orphan is
1367
1430
  // loaded; the budget is derived the same way rather than being a second, differently-wrong constant.
1368
1431
  let orphanBudgetMs = CODEX_MUTATION_BASE_MS;
@@ -1372,10 +1435,10 @@ async function codexQuarantineOrphanThread(threadId, opts) {
1372
1435
  return { ok: false, reason: `Codex native thread ${threadId} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown` };
1373
1436
  orphanBudgetMs = codexArchiveBudgetMs(rollout.bytes);
1374
1437
  }
1375
- const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation }, undefined, orphanBudgetMs);
1438
+ const archived = await codexThreadMutation(endpoint.socketPath, 'thread/archive', threadId, { dir, endpoint, generation }, undefined, orphanBudgetMs);
1376
1439
  if (!archived.ok)
1377
1440
  return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}${archived.commit === 'unknown' ? '; commit state is unknown' : ''}` };
1378
- const after = await codexColdPreflight(threadId, dir, generation);
1441
+ const after = await codexColdPreflight(threadId, dir, generation, endpoint);
1379
1442
  const failed = (reason) => ({ ok: false, reason });
1380
1443
  if (!after.ok) {
1381
1444
  const restored = await rollback();
@@ -1917,6 +1980,45 @@ export function codexRolloutBytes(threadId, root) {
1917
1980
  }
1918
1981
  return { bytes: 0 };
1919
1982
  }
1983
+ const CODEX_ROLLOUT_TAIL_BYTES = 64 * 1024;
1984
+ const CODEX_ROLLOUT_TERMINAL_EVENTS = new Set(['task_complete', 'task_completed', 'turn_complete', 'turn_completed']);
1985
+ function codexRolloutTurnSettlement(threadId, root) {
1986
+ const path = codexRolloutPath(threadId, root);
1987
+ if (!path)
1988
+ return { settled: false, reason: 'rollout is missing' };
1989
+ let fd = null;
1990
+ try {
1991
+ const size = statSync(path).size;
1992
+ if (size === 0)
1993
+ return { settled: false, reason: 'rollout has no terminal record' };
1994
+ const length = Math.min(size, CODEX_ROLLOUT_TAIL_BYTES);
1995
+ const tail = Buffer.allocUnsafe(length);
1996
+ fd = openSync(path, 'r');
1997
+ if (readSync(fd, tail, 0, length, size - length) !== length)
1998
+ return { settled: false, reason: 'rollout tail is unreadable' };
1999
+ const line = tail.toString('utf8').split('\n').reverse().find((value) => value.trim());
2000
+ if (!line)
2001
+ return { settled: false, reason: 'rollout has no terminal record' };
2002
+ let event;
2003
+ try {
2004
+ event = JSON.parse(line);
2005
+ }
2006
+ catch {
2007
+ return { settled: false, reason: 'rollout tail is incomplete or malformed' };
2008
+ }
2009
+ const payload = event && typeof event === 'object' ? event.payload : null;
2010
+ const terminal = event && typeof event === 'object' && event.type === 'event_msg' &&
2011
+ payload && typeof payload === 'object' && CODEX_ROLLOUT_TERMINAL_EVENTS.has(String(payload.type || ''));
2012
+ return terminal ? { settled: true } : { settled: false, reason: 'rollout has no terminal record' };
2013
+ }
2014
+ catch {
2015
+ return { settled: false, reason: 'rollout tail is unreadable' };
2016
+ }
2017
+ finally {
2018
+ if (fd !== null)
2019
+ closeSync(fd);
2020
+ }
2021
+ }
1920
2022
  // poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
1921
2023
  // caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
1922
2024
  // exceed launch.sh's fast-fail threshold so a genuine failure exits PAST it — the retry loop then treats it as a
@@ -2262,6 +2364,7 @@ const socketListenerLiveness = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive)
2262
2364
  const socketListenerOrPidAliveLiveness = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) => (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline');
2263
2365
  const panePidLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
2264
2366
  const recordOnline = (rec) => rec.stopped ? 'offline' : 'online';
2367
+ const sessionHomeLiveness = (_rec, tmuxAlive) => tmuxAlive ? 'online' : 'offline';
2265
2368
  // @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
2266
2369
  // socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
2267
2370
  // faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
@@ -2333,7 +2436,8 @@ export const claudeHarness = {
2333
2436
  // the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
2334
2437
  // dead-pane-reads-working bug). See rendezvousListening.
2335
2438
  liveness: socketListenerLiveness,
2336
- leafOwnerNeedle: (rec) => rec.session,
2439
+ exactNativeTargetId: (rec) => rec.session,
2440
+ deliveryTransport: claudeDeliveryTransport,
2337
2441
  deliver: (rec, text) => deliverViaClaudeRendezvous(rec.session, text, rec.mid, rec.runtimeDir),
2338
2442
  cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2339
2443
  coldRuntime: async () => ({ ok: true }),
@@ -2359,18 +2463,20 @@ export const claudeHeadlessHarness = {
2359
2463
  id: 'claude-headless',
2360
2464
  sessionEnvVar: harnessIdentity('claude-headless').sessionEnvVar,
2361
2465
  headless: true,
2362
- runtimeOwnership: 'adapter',
2466
+ runtimeOwnership: 'leaf',
2363
2467
  ownsRendezvous: false,
2364
2468
  paneTitleIsSelfSummary: false,
2365
2469
  launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
2366
2470
  launchEnv: noLaunchEnv,
2367
- // Liveness is the intact, non-stopped record's property. A missing controller/child fails loudly at control
2368
- // time rather than turning an idle (no child) session into a speculative offline row.
2369
- liveness: recordOnline,
2471
+ liveness: sessionHomeLiveness,
2472
+ deliveryTransport: unprovenDeliveryTransport,
2370
2473
  deliver: deliverViaClaudeHeadless,
2371
2474
  interrupt: interruptClaudeHeadless,
2372
2475
  cleanupRuntime: (rec) => unlinkSocks(claudeHeadlessSock(rec.session)),
2373
- coldRuntime: async () => ({ ok: false, reason: 'claude-headless has no exact resident unload verification' }),
2476
+ coldRuntime: async (rec) => {
2477
+ const result = await claudeHeadlessColdRuntime(rec);
2478
+ return result.ok ? { ok: true } : { ok: false, reason: result.error || 'claude-headless runtime remains unproven' };
2479
+ },
2374
2480
  deliveryBlockedBy: undefined,
2375
2481
  };
2376
2482
  function codexRuntimeDescriptor(endpoint, runtimeDir) {
@@ -2405,8 +2511,16 @@ function codexRuntimeDescriptors(runtimeDir) {
2405
2511
  return (endpoints.length ? endpoints : [legacyCodexGenerationEndpoint(runtimeDir)])
2406
2512
  .map((endpoint) => codexRuntimeDescriptor(endpoint, runtimeDir));
2407
2513
  }
2514
+ function codexResumeArg(rec, pendingLaunchPayload) {
2515
+ if (rec.harnessSessionId)
2516
+ return `--resume ${rec.harnessSessionId}`;
2517
+ if (pendingLaunchPayload == null)
2518
+ throw new Error(`session ${rec.session}: native identity is absent and the authoritative resolved launch payload is missing; refusing to create an empty thread`);
2519
+ return shQuote(pendingLaunchPayload);
2520
+ }
2408
2521
  export const codexHarness = {
2409
2522
  id: 'codex',
2523
+ launchPayloadProof: true,
2410
2524
  dispatchId: 'codex',
2411
2525
  headless: false,
2412
2526
  sharedRuntimeSpawn: true,
@@ -2477,7 +2591,7 @@ export const codexHarness = {
2477
2591
  return pane.pidAlive ? 'online' : 'offline';
2478
2592
  return paneTreeRunsCodex(pane) ? 'online' : 'offline';
2479
2593
  },
2480
- leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
2594
+ exactNativeTargetId: (rec) => rec.harnessSessionId || null,
2481
2595
  deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
2482
2596
  observeTurnFailures: codexTurnFailureObserver,
2483
2597
  interrupt: interruptCodexTurn,
@@ -2641,8 +2755,8 @@ export const codexHarness = {
2641
2755
  sharedRuntimes: codexRuntimeDescriptors,
2642
2756
  // owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
2643
2757
  // a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
2644
- // the owned id, the SAME conversation); none empty tail relaunch a FRESH thread on the same worktree/record.
2645
- resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
2758
+ // the owned id, the SAME conversation); no identityreplay the authoritative resolved launch payload.
2759
+ resumeArg: codexResumeArg,
2646
2760
  // codex's own settled failure: a thread id whose rollout is not on disk can never be resumed, so the launch
2647
2761
  // that says so has already decided. (Its transient sibling — the rollout still being written — is handled
2648
2762
  // BEFORE launch by waitForCodexRollout, so what reaches here is the permanent case.)
@@ -2754,6 +2868,7 @@ async function codexHeadlessReadinessProof(current) {
2754
2868
  export const codexHeadlessHarness = {
2755
2869
  ...codexHarness,
2756
2870
  id: 'codex-headless',
2871
+ launchPayloadProof: true,
2757
2872
  sessionEnvVar: harnessIdentity('codex-headless').sessionEnvVar,
2758
2873
  headless: true,
2759
2874
  runtimeOwnership: 'adapter',
@@ -2779,9 +2894,9 @@ export const codexHeadlessHarness = {
2779
2894
  await new Promise((resolve) => setTimeout(resolve, Math.min(200, remaining)));
2780
2895
  }
2781
2896
  },
2782
- // There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
2783
- // runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
2784
- resumeArg: () => '',
2897
+ // There is no TUI to restart and the project app-server keeps an identified thread addressable. A pre-identity
2898
+ // recovery still replays the authoritative resolved launch payload through codex-launch.
2899
+ resumeArg: codexResumeArg,
2785
2900
  };
2786
2901
  // @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
2787
2902
  // the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
@@ -2824,7 +2939,8 @@ export const piHarness = {
2824
2939
  // claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
2825
2940
  // socket the generated extension binds. socketLive is already probed for every windowed session.
2826
2941
  liveness: socketListenerLiveness,
2827
- leafOwnerNeedle: (rec) => rec.session,
2942
+ exactNativeTargetId: (rec) => rec.session,
2943
+ deliveryTransport: rendezvousDeliveryTransport,
2828
2944
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2829
2945
  cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2830
2946
  coldRuntime: async () => ({ ok: true }),
@@ -2835,18 +2951,19 @@ export const piHarness = {
2835
2951
  // pi-headless is an independent harness: its materialization surface is literally pi's, while a resident
2836
2952
  // controller owns non-interactive text-mode turns. Active turns steer through pi's rendezvous extension;
2837
2953
  // idle delivery cold-wakes the exact saved session with `--session` (never `--session-id`, which would create a
2838
- // new conversation). The controller deliberately reports record-backed liveness, matching Claude headless.
2954
+ // new conversation). The exact tmux home is its public addressability and physical-cold boundary.
2839
2955
  export const piHeadlessHarness = {
2840
2956
  ...piHarness,
2841
2957
  id: 'pi-headless',
2842
2958
  sessionEnvVar: harnessIdentity('pi-headless').sessionEnvVar,
2843
2959
  headless: true,
2960
+ deliveryTransport: unprovenDeliveryTransport,
2844
2961
  // The controller is a per-session process launched in the target tmux pane. Its launch-registered PID
2845
- // and argv session id are exact leaf ownership evidence; record-backed liveness does not make it shared.
2962
+ // and argv session id are exact leaf ownership evidence.
2846
2963
  runtimeOwnership: 'leaf',
2847
2964
  paneTitleIsSelfSummary: false,
2848
2965
  launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
2849
- liveness: recordOnline,
2966
+ liveness: sessionHomeLiveness,
2850
2967
  deliver: deliverViaPiHeadless,
2851
2968
  cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
2852
2969
  coldRuntime: async (rec) => {
@@ -2888,7 +3005,7 @@ export const zcodeHarness = {
2888
3005
  clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject); },
2889
3006
  slashCommands: () => [],
2890
3007
  liveness: panePidLiveness,
2891
- leafOwnerNeedle: (rec) => rec.session,
3008
+ exactNativeTargetId: () => null,
2892
3009
  deliver: async () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE); },
2893
3010
  cleanupRuntime: async () => { },
2894
3011
  coldRuntime: async () => ({ ok: true }),
@@ -2934,7 +3051,8 @@ export const opencodeHarness = {
2934
3051
  // (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
2935
3052
  // load still reads honestly from the process signal instead of a false offline.
2936
3053
  liveness: socketListenerOrPidAliveLiveness,
2937
- leafOwnerNeedle: (rec) => rec.session,
3054
+ exactNativeTargetId: (rec) => rec.harnessSessionId || null,
3055
+ deliveryTransport: rendezvousDeliveryTransport,
2938
3056
  deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
2939
3057
  cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
2940
3058
  coldRuntime: async () => ({ ok: true }),
@@ -2951,12 +3069,14 @@ export const opencodeHeadlessHarness = {
2951
3069
  id: 'opencode-headless',
2952
3070
  sessionEnvVar: harnessIdentity('opencode-headless').sessionEnvVar,
2953
3071
  headless: true,
2954
- runtimeOwnership: 'adapter',
3072
+ runtimeOwnership: 'leaf',
3073
+ deliveryTransport: unprovenDeliveryTransport,
2955
3074
  launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
2956
- // A sleeping native conversation is still addressable by its non-stopped record. Transport breakage belongs
2957
- // to the next delivery, where the live rendezvous or pane wake reports it loudly.
2958
- liveness: recordOnline,
2959
- coldRuntime: async () => ({ ok: false, reason: 'opencode-headless has no exact resident unload verification' }),
3075
+ liveness: sessionHomeLiveness,
3076
+ coldRuntime: async (rec) => {
3077
+ const result = await opencodeHeadlessColdRuntime(rec);
3078
+ return result.ok ? { ok: true } : { ok: false, reason: result.error || 'opencode-headless runtime remains unproven' };
3079
+ },
2960
3080
  deliver: async (rec, text) => {
2961
3081
  return deliverViaSocketOrWake(rec.session, text, rec.mid, () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)), `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`);
2962
3082
  },
@@ -1,4 +1,9 @@
1
1
  export declare function commandHelp(name: string, verb?: string): string | null;
2
+ export type PublicCommand = Readonly<{
3
+ name: string;
4
+ text: string;
5
+ }>;
6
+ export declare function publicCommands(): readonly PublicCommand[];
2
7
  export type HelpCatalogEntry = Readonly<{
3
8
  id: string;
4
9
  title: string;
@@ -36,7 +36,7 @@ session to that node. --prompt-file <path>|- carries a long prompt without shell
36
36
  reproduced against a frozen commit; a base that names no commit is refused before anything is created.
37
37
  The successful receipt names what to read, monitor, and reply on. --ssh uses an existing gateway-to-gateway
38
38
  communication tunnel: its full id anchors the remote project, creation stays parentless and remote, and its
39
- prompt carries a runnable reply path over that same tunnel.`],
39
+ prompt carries a runnable reply path over that same tunnel.`, ['project-bound']],
40
40
  ls: [['spex session ls [SEL…] [--children[=<PARENT-SEL>]] [--status a,b] [--all] [--json]', 'spex session ls --ssh <address> <FULL-SESSION-ID> [--children=<PARENT-SEL>] [--status a,b] [--json]'],
41
41
  'One-shot table of this project\'s live sessions, with each direct parent beside the row. --children scopes it to the caller\'s direct children; --children=<PARENT-SEL> names another parent without changing positional selector grammar. The heading summarizes the displayed scope by status. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it. An explicit id missing from the session list is diagnosed from terminal-close history: closed is a successful answer, while no live, archived, or closed history is a named miss. --ssh uses an existing gateway-to-gateway communication tunnel; its full id anchors one remote project rather than filtering the table, and archive projection stays unavailable on that peer route.', ['selector']],
42
42
  resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
@@ -96,8 +96,8 @@ const SESSION_HELP_GROUPS = [
96
96
  { title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask', 'files', 'web'] },
97
97
  { title: 'Human escape hatch', verbs: ['attach'] },
98
98
  ];
99
- const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge/reparent) are PROJECT-BOUND: a backend serving
100
- another project's repo refuses loudly — name the target with --api <url> to drive it on purpose.`;
99
+ const SESSION_WRITE_NOTE = `PROJECT-BOUND WRITES loudly refuse a backend serving another project's repo —
100
+ name the target with --api <url> to drive it on purpose.`;
101
101
  function indent(text, spaces) {
102
102
  const prefix = ' '.repeat(spaces);
103
103
  return text.split('\n').map((line) => `${prefix}${line}`).join('\n');
@@ -127,7 +127,7 @@ function sessionVerbHelp(verb) {
127
127
  const ENTRIES = {
128
128
  // ── project verbs (implicit object = this project) ────────────────────────
129
129
  graph: {
130
- line: 'graph the assembled view: bare = readable tree · --json = full payload · --public = static graph snapshot',
130
+ line: 'graph list the assembled spec nodes: bare = readable tree · --json = full payload · --public = static graph snapshot',
131
131
  body: `Usage: spex graph [--focus <id>] [--depth N] [--json] | spex graph --public [--out <path>] [--content-dir <path>]
132
132
 
133
133
  The normal assembled view is merged spec tree + worktree overlay + sessions. Bare it renders the
@@ -170,15 +170,22 @@ hooks yet (CI, a cloud agent): generated and excluded, the artifacts never arriv
170
170
  see: 'spex doctor (verify the materialized artifacts actually reach an agent)',
171
171
  },
172
172
  doctor: {
173
- line: 'doctor diagnose spec health and whether the workflow reaches this agent [--contract|--conflicts]',
173
+ line: 'doctor diagnose project health; repair app-server only on explicit request [--contract|--conflicts]',
174
174
  body: `Usage: spex doctor spec-health findings + delivery report: preconditions · git-hook floor ·
175
175
  contract · hooks + handler existence · backend · footprint
176
176
  spex doctor --contract print the composed surface:system text any agent here reads
177
177
  spex doctor --conflicts detect double-delivery (loose artifacts beside the managed ones)
178
+ spex doctor repair app-server [--launcher <name>]
179
+ prove a fresh app-server, then switch new sessions to it
178
180
 
179
181
  Bare doctor is the opt-in, read-only health surface: it reports altitude and breadth findings without
180
182
  putting them in the lint gate, then audits workflow delivery. Run it directly or let the tidy workflow
181
- consume the same visible diagnosis.`,
183
+ consume the same visible diagnosis.
184
+
185
+ Use repair app-server only when new sessions cannot be accepted and existing work must stay connected.
186
+ It proves a fresh app-server before routing future Codex sessions there. Existing sessions stay on the
187
+ previous server while it drains; the command does not kill or move them. --launcher selects a configured
188
+ Codex launcher, or the configured default is used when it is one.`,
182
189
  see: 'spex spec lint (deterministic graph/contract gate) · spex materialize (repair delivery artifacts)',
183
190
  },
184
191
  flat: {
@@ -495,6 +502,14 @@ export function commandHelp(name, verb) {
495
502
  if (exact)
496
503
  return `${exact}\n\nsee also: spex session (the complete drawer)\n\nmap: spex help · skills: spex guide`;
497
504
  }
505
+ if (name === 'doctor' && verb === 'repair') {
506
+ return `Usage: spex doctor repair app-server [--launcher <name>]
507
+
508
+ Use this explicit repair when new sessions cannot be accepted but existing work must stay connected.
509
+ It proves a fresh app-server, then routes future Codex sessions there. Existing sessions stay bound to
510
+ the prior server while it drains; this command never kills or moves them. --launcher must name a
511
+ configured Codex launcher.\n\nsee also: spex doctor (the complete command) · spex session resources\n\nmap: spex help · skills: spex guide`;
512
+ }
498
513
  const e = ENTRIES[name];
499
514
  if (!e)
500
515
  return null;
@@ -502,6 +517,11 @@ export function commandHelp(name, verb) {
502
517
  const header = oneLiner ? `spex ${name} — ${oneLiner}\n\n` : ''; // unlisted entries (internal, help) lead with their own Usage
503
518
  return `${header}${e.body}${e.see ? `\n\nsee also: ${e.see}` : ''}\n\nmap: spex help · skills: spex guide`;
504
519
  }
520
+ export function publicCommands() {
521
+ return Object.entries(ENTRIES)
522
+ .filter(([name]) => name !== 'internal')
523
+ .map(([name, entry]) => ({ name, text: `${entry.line}\n${entry.body}` }));
524
+ }
505
525
  // The guidance catalog consumes this registry projection rather than copying help prose or maintaining a
506
526
  // second command list. Each returned text is exactly what the corresponding public help probe prints.
507
527
  export function helpCatalogEntries() {
@@ -751,8 +751,9 @@ app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
751
751
  }));
752
752
  // ONE input route, `kind` the discriminator — the transport split is an implementation fact, not API surface.
753
753
  // kind:"text" (`spex session send`, the server-side merge dispatch) appends the prompt to the
754
- // target timeline, then best-effort pokes its adapter. A dead channel delays context injection but does not
755
- // change the successful append response; 502 means the record rejected the write.
754
+ // target timeline, then best-effort pokes its adapter. A proven-unreachable transport joined to a live
755
+ // registered agent is stranded and refuses before append; an unproven/dead-restartable channel stays queued.
756
+ // 502 means the record rejected the write.
756
757
  // kind:"keys" is the LAST-RESORT raw face (`spex session send --keys`): an ORDERED BATCH of
757
758
  // nav-mode key tokens over tmux send-keys, delivered in array order so tap order survives
758
759
  // ([[nav-mode-key-ordering]]); unstable by nature — callers try a plain text send first. An unknown kind is a
@@ -878,7 +879,6 @@ superviseBridges(); // restore visible helpers after failure; their viewer subsc
878
879
  superviseQueue(); // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
879
880
  superviseTurnFailures(); // reconcile adapter-owned native failure subscriptions across backend replacement
880
881
  superviseDelivery(); // hand over messages an earlier pass could not ([[delivery-queue]]): the retry half of dispatch
881
- console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`);
882
882
  let graphWatchersClosed = false;
883
883
  const closeGraphWatchers = () => {
884
884
  if (graphWatchersClosed)
@@ -3,5 +3,6 @@ export declare function listenOrExit(server: Server, port: number, opts: {
3
3
  host?: string;
4
4
  label: string;
5
5
  cleanup?: () => void;
6
- onListen: () => void;
6
+ onListen?: () => void;
7
+ ready: string | string[];
7
8
  }): void;
@@ -1,11 +1,6 @@
1
- // @@@ listenOrExit - the shared "I own this port; if I cannot bind it, I have failed" contract for the two
2
- // public-port listeners: the supervisor's raw-TCP proxy (supervise.ts) and the dashboard/public gateway
3
- // (gateway.ts). A bind failure is the ONE thing neither may survive it is the opposite of the keep-serving
4
- // process guard, which rides out transient throws once the port is already held. So instead of leaving the
5
- // listen error unhandled (under `serve` the supervisor's uncaughtException guard would SWALLOW it into a
6
- // portless zombie on a random child port; under `dashboard`, with no guard, it would crash with a raw stack),
7
- // we attach one handler that fails loudly the same way on both surfaces: name the busy port and the repair,
8
- // reap any child booted for this bind so none is orphaned, and exit non-zero.
1
+ // @@@ listenOrExit ([[listener-readiness]]) - the one public-listener transition: before `listening`, a bind
2
+ // failure is loud and fatal; after it, publication side effects and user-visible ready lines may run. Keeping
3
+ // both halves here prevents a private child or pre-bind caller from announcing a surface it does not own.
9
4
  //
10
5
  // http.Server / https.Server both extend net.Server, so this one signature covers every caller.
11
6
  export function listenOrExit(server, port, opts) {
@@ -17,8 +12,13 @@ export function listenOrExit(server, port, opts) {
17
12
  console.error(`spec-cli: ${opts.label} cannot bind — ${why}. Free :${port} (e.g. lsof -i :${port}) or pick another port, then retry.`);
18
13
  process.exit(1);
19
14
  });
15
+ const publishReady = () => {
16
+ opts.onListen?.();
17
+ for (const line of Array.isArray(opts.ready) ? opts.ready : [opts.ready])
18
+ console.log(line);
19
+ };
20
20
  if (opts.host)
21
- server.listen(port, opts.host, opts.onListen);
21
+ server.listen(port, opts.host, publishReady);
22
22
  else
23
- server.listen(port, opts.onListen);
23
+ server.listen(port, publishReady);
24
24
  }
@@ -1,4 +1,5 @@
1
1
  import { type DispatchResult, type HarnessDeliveryRecord } from './harness.js';
2
2
  export declare function opencodeHeadlessLaunchCommand(opencodeCmd?: string): string;
3
3
  export declare function opencodeHeadlessWakeCommand(opencodeCmd: string, harnessSessionId: string | null | undefined, text: string, outcomePath?: string): string;
4
+ export declare function opencodeHeadlessColdRuntime(rec: Pick<HarnessDeliveryRecord, 'session'>): Promise<DispatchResult>;
4
5
  export declare function spawnOpenCodeHeadlessTurn(rec: HarnessDeliveryRecord, text: string, opencodeCmd: string, socketPath: string): Promise<DispatchResult>;
@@ -97,6 +97,13 @@ export function opencodeHeadlessWakeCommand(opencodeCmd, harnessSessionId, text,
97
97
  ];
98
98
  return turnHome([...runPrelude(opencodeCmd), ...resume].join('\n'), outcomePath);
99
99
  }
100
+ export async function opencodeHeadlessColdRuntime(rec) {
101
+ const { rendezvousListening } = await import('./harness.js');
102
+ const probe = await rendezvousListening(rec.session);
103
+ return probe === 'dead'
104
+ ? { ok: true }
105
+ : { ok: false, error: `opencode-headless rendezvous is still ${probe === 'live' ? 'live' : 'unproven'}` };
106
+ }
100
107
  function readTurnOutcome(path) {
101
108
  let value;
102
109
  try {
@@ -0,0 +1 @@
1
+ export declare function runDoctorRepairAppServer(args: string[]): Promise<number>;