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.
- package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.d.ts +1 -1
- package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.js +18 -2
- package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
- package/node_modules/@spexcode/session-core/dist/index.js +5 -0
- package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
- package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
- package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
- package/node_modules/@spexcode/session-core/dist/message.js +52 -0
- package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
- package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
- package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +50 -0
- package/node_modules/@spexcode/session-core/dist/runtime-session.js +286 -0
- package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +46 -0
- package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
- package/node_modules/@spexcode/session-core/package.json +33 -0
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +2 -1
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +4 -1
- package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +13 -4
- package/node_modules/@spexcode/spec-cli/dist/cli.js +72 -23
- package/node_modules/@spexcode/spec-cli/dist/client.d.ts +2 -1
- package/node_modules/@spexcode/spec-cli/dist/client.js +13 -8
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +5 -0
- package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +112 -0
- package/node_modules/@spexcode/spec-cli/dist/doctor.js +7 -1
- package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +7 -5
- package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/gateway.js +44 -20
- package/node_modules/@spexcode/spec-cli/dist/graphCache.js +2 -1
- package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +2 -1
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -2
- package/node_modules/@spexcode/spec-cli/dist/harness.js +174 -54
- package/node_modules/@spexcode/spec-cli/dist/help.d.ts +5 -0
- package/node_modules/@spexcode/spec-cli/dist/help.js +26 -6
- package/node_modules/@spexcode/spec-cli/dist/index.js +3 -3
- package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +2 -1
- package/node_modules/@spexcode/spec-cli/dist/listen.js +10 -10
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +7 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.d.ts +1 -0
- package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -0
- package/node_modules/@spexcode/spec-cli/dist/session-follow.js +1 -1
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +6 -46
- package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +8 -221
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +25 -4
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +822 -394
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +3 -3
- package/node_modules/@spexcode/spec-cli/package.json +6 -4
- package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/dist/git.js +32 -0
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +4 -0
- package/node_modules/@spexcode/spec-core/package.json +1 -1
- package/node_modules/@spexcode/spec-eval/package.json +2 -2
- package/node_modules/@spexcode/spec-forge/package.json +2 -2
- package/package.json +3 -3
- /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.d.ts +0 -0
- /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
|
|
71
|
-
// no
|
|
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
|
-
// @@@
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// this
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 =
|
|
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,
|
|
509
|
-
//
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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: '
|
|
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
|
-
|
|
2368
|
-
|
|
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 () =>
|
|
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
|
-
|
|
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);
|
|
2645
|
-
resumeArg:
|
|
2758
|
+
// the owned id, the SAME conversation); no identity → replay 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
|
|
2783
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
3072
|
+
runtimeOwnership: 'leaf',
|
|
3073
|
+
deliveryTransport: unprovenDeliveryTransport,
|
|
2955
3074
|
launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
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 = `
|
|
100
|
-
|
|
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
|
|
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
|
|
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
|
|
755
|
-
//
|
|
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)
|
|
@@ -1,11 +1,6 @@
|
|
|
1
|
-
// @@@ listenOrExit - the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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,
|
|
21
|
+
server.listen(port, opts.host, publishReady);
|
|
22
22
|
else
|
|
23
|
-
server.listen(port,
|
|
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>;
|