@spexcode/spec-cli 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.
package/dist/gateway.js CHANGED
@@ -178,17 +178,15 @@ export function startGateway(opts) {
178
178
  // narrows the public gateway's reach. The gate note keys on LOOPBACK, not on host-being-explicit:
179
179
  // an ungated loopback bind is normal, an ungated wide bind is announced — never silent.
180
180
  const isLoopback = opts.host === '127.0.0.1' || opts.host === 'localhost' || opts.host === '::1';
181
- const onListen = () => {
182
- const scheme = secure ? 'https' : 'http';
183
- const label = opts.label ?? 'public mode';
184
- const gate = isLoopback ? '' : ` ${gated ? 'password-gated' : 'OPEN (no password)'}`;
185
- console.log(`[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`);
186
- if (!secure && !isLoopback && !opts.host)
187
- console.log('[gateway] (TLS off — --http)');
188
- };
181
+ const scheme = secure ? 'https' : 'http';
182
+ const label = opts.label ?? 'public mode';
183
+ const gate = isLoopback ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}`;
184
+ const ready = [...(opts.readyLines ?? []), `[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`];
185
+ if (!secure && !isLoopback && !opts.host)
186
+ ready.push('[gateway] (TLS off --http)');
189
187
  // a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
190
188
  // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
191
- listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen });
189
+ listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, ready });
192
190
  }
193
191
  // re-serialize an upgrade request's headers for replay against the upstream (exported for the host
194
192
  // gateway's per-project WS pipe, which replays the same way).
@@ -223,6 +221,16 @@ function doLogin(req, res, password, setCookie) {
223
221
  // and would fight Range requests.
224
222
  const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg)/;
225
223
  const wantsGzip = (req) => /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''));
224
+ // zlib's larger default table can be counterproductive for minified text: memLevel 5 both compresses it
225
+ // further and lowers each stream's working memory. One policy drives buffered and streamed gzip.
226
+ const GZIP_OPTIONS = { level: 9, memLevel: 5 };
227
+ function appendVary(current, token) {
228
+ const values = (Array.isArray(current) ? current : [current ?? ''])
229
+ .flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean);
230
+ if (!values.some((value) => value === '*' || value.toLowerCase() === token.toLowerCase()))
231
+ values.push(token);
232
+ return values.join(', ');
233
+ }
226
234
  // reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
227
235
  // stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
228
236
  // `path` and `headers` optionally override routing inputs (the host gateway strips its /p/:projectId
@@ -322,16 +330,19 @@ export function proxyHttp(req, res, upstreamPort, path, headers = req.headers, u
322
330
  received.once('close', () => { if (!received.complete)
323
331
  failFromUpstream(); });
324
332
  const type = String(received.headers['content-type'] || '');
325
- const skip = !wantsGzip(req) || received.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream');
326
- if (skip) {
327
- res.writeHead(received.statusCode || 502, received.headers);
333
+ const eligible = !received.headers['content-encoding'] && COMPRESSIBLE.test(type) && !type.startsWith('text/event-stream');
334
+ const responseHeaders = eligible
335
+ ? { ...received.headers, vary: appendVary(received.headers.vary, 'Accept-Encoding') }
336
+ : received.headers;
337
+ if (!eligible || !wantsGzip(req)) {
338
+ res.writeHead(received.statusCode || 502, responseHeaders);
328
339
  received.pipe(res);
329
340
  return;
330
341
  }
331
- const headers = { ...received.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' };
342
+ const headers = { ...responseHeaders, 'content-encoding': 'gzip' };
332
343
  delete headers['content-length']; // streamed; the encoded length isn't knowable up front
333
344
  res.writeHead(received.statusCode || 502, headers);
334
- transform = createGzip();
345
+ transform = createGzip(GZIP_OPTIONS);
335
346
  transform.once('error', failFromUpstream);
336
347
  received.pipe(transform).pipe(res);
337
348
  });
@@ -473,17 +484,21 @@ export function serveStatic(req, res, distDir, urlPath) {
473
484
  const type = MIME[extname(file)] || 'application/octet-stream';
474
485
  const cacheControl = /[\\/]assets[\\/]/.test(file) ? 'public, max-age=31536000, immutable' : 'no-cache';
475
486
  const raw = readFileSync(file);
476
- if (wantsGzip(req) && COMPRESSIBLE.test(type)) {
487
+ const compressible = COMPRESSIBLE.test(type);
488
+ const headers = { 'Content-Type': type, 'Cache-Control': cacheControl };
489
+ if (compressible)
490
+ headers.Vary = appendVary(undefined, 'Accept-Encoding');
491
+ if (wantsGzip(req) && compressible) {
477
492
  const mtime = statSync(file).mtimeMs;
478
493
  let hit = gzMemo.get(file);
479
494
  if (!hit || hit.mtime !== mtime) {
480
- hit = { mtime, gz: gzipSync(raw) };
495
+ hit = { mtime, gz: gzipSync(raw, GZIP_OPTIONS) };
481
496
  gzMemo.set(file, hit);
482
497
  }
483
- res.writeHead(200, { 'Content-Type': type, 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding', 'Cache-Control': cacheControl });
498
+ res.writeHead(200, { ...headers, 'Content-Encoding': 'gzip' });
484
499
  return res.end(hit.gz);
485
500
  }
486
- res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cacheControl });
501
+ res.writeHead(200, headers);
487
502
  res.end(raw);
488
503
  }
489
504
  function sendHtml(res, status, html) {
@@ -498,6 +513,15 @@ function sendHtml(res, status, html) {
498
513
  // installed user has no source tree for). See [[packaging]].
499
514
  export function serveDashboardLocal(opts) {
500
515
  const distDir = resolveDistDir();
501
- console.log(`[dashboard] serving ${distDir}, /api → backend :${opts.apiPort}`);
502
- startGateway({ host: opts.host ?? '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard', projectRoot: opts.projectRoot });
516
+ startGateway({
517
+ host: opts.host ?? '127.0.0.1',
518
+ publicPort: opts.port,
519
+ upstreamPort: opts.apiPort,
520
+ password: '',
521
+ tls: null,
522
+ distDir,
523
+ label: 'dashboard',
524
+ projectRoot: opts.projectRoot,
525
+ readyLines: [`[dashboard] serving ${distDir}, /api → backend :${opts.apiPort}`],
526
+ });
503
527
  }
@@ -3,7 +3,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
3
3
  import { isAbsolute, join, resolve } from 'node:path';
4
4
  import { rebasePublishedSessions } from '@spexcode/spec-core';
5
5
  import { buildBoard, spliceSessions } from './graphSnapshot.js';
6
- import { headSha, repoRoot, withGitAbortSignal } from '@spexcode/spec-core';
6
+ import { headSha, repoRoot, requireGitWorkspace, withGitAbortSignal } from '@spexcode/spec-core';
7
7
  import { listSessionIds, mainBranch, mainCheckout, readPublicRecordEntry, sessionArtifactPath, sessionRecordPath } from '@spexcode/spec-core';
8
8
  import { boardThreads } from './issues.js';
9
9
  import { resolveForgeHost } from '@spexcode/spec-forge/drivers';
@@ -162,6 +162,7 @@ function sessionInputRevision() {
162
162
  }
163
163
  function boardInputRevision(board) {
164
164
  const root = repoRoot();
165
+ requireGitWorkspace(root);
165
166
  const session = sessionInputRevision();
166
167
  // Durable active records are the current root set; a cached ordinary row may be stale and must not replace
167
168
  // them. The one projection-only addition is explicit: listSessions republishes an archived-runtime hazard
@@ -1,4 +1,4 @@
1
- import { loadSpecs } from '@spexcode/spec-core';
1
+ import { loadSpecs, requireGitWorkspace } from '@spexcode/spec-core';
2
2
  import { resolveLayout } from '@spexcode/spec-core';
3
3
  import { listSessions } from './sessions.js';
4
4
  import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
@@ -12,6 +12,7 @@ import { sessionEvalProjections } from '@spexcode/spec-eval/sessioneval';
12
12
  // The application adapter is the sole reader of runtime/forge state. graph.ts only receives this result.
13
13
  export async function boardSnapshot() {
14
14
  const root = repoRoot();
15
+ requireGitWorkspace(root);
15
16
  const [specs, sessions] = await Promise.all([loadSpecs(), listSessions()]);
16
17
  const layout = await resolveLayout({ activeSessionIds: sessions.map((session) => session.id) });
17
18
  const nodeIds = [...new Set([
package/dist/harness.d.ts CHANGED
@@ -24,6 +24,14 @@ export type FailureSubscription = {
24
24
  close(): void;
25
25
  readonly closed: Promise<string | null>;
26
26
  };
27
+ export type DeliveryTransportState = {
28
+ kind: 'reachable';
29
+ } | {
30
+ kind: 'unproven';
31
+ } | {
32
+ kind: 'unreachable';
33
+ reason: string;
34
+ };
27
35
  export type ProcTable = Map<number, {
28
36
  ppid: number;
29
37
  comm: string;
@@ -137,10 +145,12 @@ export interface Harness {
137
145
  slashCommands(): SlashCommand[];
138
146
  liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline';
139
147
  launchReady?(current: () => HarnessLaunchReadyRecord | null, deadline: number): Promise<HarnessLaunchReadinessFence | null>;
140
- leafOwnerNeedle?(rec: HarnessLivenessRecord & {
148
+ launchPayloadProof?: true;
149
+ exactNativeTargetId(rec: HarnessLivenessRecord & {
141
150
  harnessSessionId?: string | null;
142
151
  }): string | null;
143
152
  deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>;
153
+ deliveryTransport?(rec: HarnessDeliveryRecord): Promise<DeliveryTransportState>;
144
154
  observeTurnFailures?(rec: HarnessDeliveryRecord, onFailure: (failure: TurnFailure) => void): FailureSubscription;
145
155
  interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>;
146
156
  cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>;
@@ -185,7 +195,7 @@ export interface Harness {
185
195
  resumeArg(rec: {
186
196
  session: string;
187
197
  harnessSessionId?: string | null;
188
- }): string;
198
+ }, pendingLaunchPayload?: string | null): string;
189
199
  }
190
200
  export type DispatchResult = {
191
201
  ok: boolean;
@@ -193,6 +203,8 @@ export type DispatchResult = {
193
203
  };
194
204
  export type HarnessDeliveryRecord = {
195
205
  session: string;
206
+ stopped?: boolean;
207
+ archived?: boolean;
196
208
  worktreePath?: string;
197
209
  harnessSessionId?: string | null;
198
210
  runtimeDir?: string;
@@ -205,6 +217,7 @@ export type HarnessArtifacts = {
205
217
  };
206
218
  export declare const legacyRvSock: (id: string) => string;
207
219
  export declare const scopedRvSock: (id: string, dir?: string) => string;
220
+ export declare function assertRvSockPath(id: string, dir?: string): string;
208
221
  export declare const rvSock: (id: string) => string;
209
222
  export declare function stampRvSock(id: string, dir?: string): string;
210
223
  export type ListenerProbe = 'live' | 'dead' | 'unproven';
package/dist/harness.js CHANGED
@@ -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
  },