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