spexcode 0.6.8 → 0.7.0-next.0
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/session-application/package.json +5 -5
- package/node_modules/@spexcode/session-events/package.json +2 -2
- package/node_modules/@spexcode/session-protocol/package.json +1 -1
- package/node_modules/@spexcode/session-runtime/package.json +2 -2
- package/node_modules/@spexcode/session-selflaunch/dist/locality.d.ts +12 -1
- package/node_modules/@spexcode/session-selflaunch/dist/locality.js +62 -10
- package/node_modules/@spexcode/session-selflaunch/package.json +3 -3
- package/node_modules/@spexcode/session-topology/package.json +2 -2
- package/node_modules/@spexcode/spec-cli/bin/spex.mjs +127 -10
- package/node_modules/@spexcode/spec-cli/dist/cli.js +19 -0
- package/node_modules/@spexcode/spec-cli/dist/file-write.js +14 -2
- package/node_modules/@spexcode/spec-cli/dist/guide.js +9 -11
- package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +11 -5
- package/node_modules/@spexcode/spec-cli/dist/harness.js +185 -28
- package/node_modules/@spexcode/spec-cli/dist/host.js +5 -1
- package/node_modules/@spexcode/spec-cli/dist/index.js +21 -65
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.d.ts +2 -1
- package/node_modules/@spexcode/spec-cli/dist/machine-peer.js +45 -14
- package/node_modules/@spexcode/spec-cli/dist/opencode.js +10 -2
- package/node_modules/@spexcode/spec-cli/dist/pi-harness.js +18 -5
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.d.ts +2 -0
- package/node_modules/@spexcode/spec-cli/dist/pi-headless.js +27 -0
- package/node_modules/@spexcode/spec-cli/dist/session-transcript.d.ts +57 -0
- package/node_modules/@spexcode/spec-cli/dist/session-transcript.js +137 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +4 -0
- package/node_modules/@spexcode/spec-cli/dist/sessions.js +103 -35
- package/node_modules/@spexcode/spec-cli/dist/shim-runtime.js +14 -0
- package/node_modules/@spexcode/spec-cli/dist/supervise.js +1 -1
- package/node_modules/@spexcode/spec-cli/package.json +8 -6
- package/node_modules/@spexcode/spec-core/dist/layout.d.ts +0 -1
- package/node_modules/@spexcode/spec-core/package.json +1 -1
- package/node_modules/@spexcode/spec-core/templates/spexcode.json +36 -13
- package/node_modules/@spexcode/spec-eval/package.json +2 -2
- package/node_modules/@spexcode/spec-forge/package.json +2 -2
- package/node_modules/@spexcode/transcript/dist/frames.d.ts +44 -0
- package/node_modules/@spexcode/transcript/dist/frames.js +85 -0
- package/node_modules/@spexcode/transcript/dist/index.d.ts +5 -0
- package/node_modules/@spexcode/transcript/dist/index.js +5 -0
- package/node_modules/@spexcode/transcript/dist/live.d.ts +16 -0
- package/node_modules/@spexcode/transcript/dist/live.js +65 -0
- package/node_modules/@spexcode/transcript/dist/parsers.d.ts +47 -0
- package/node_modules/@spexcode/transcript/dist/parsers.js +285 -0
- package/node_modules/@spexcode/transcript/dist/readers.d.ts +10 -0
- package/node_modules/@spexcode/transcript/dist/readers.js +296 -0
- package/node_modules/@spexcode/{spec-cli/dist/transcript-reader.d.ts → transcript/dist/turns.d.ts} +11 -5
- package/node_modules/@spexcode/transcript/dist/turns.js +11 -0
- package/node_modules/@spexcode/transcript/package.json +30 -0
- package/node_modules/smol-toml/LICENSE +24 -0
- package/node_modules/smol-toml/README.md +418 -0
- package/node_modules/smol-toml/dist/date.d.ts +41 -0
- package/node_modules/smol-toml/dist/date.js +127 -0
- package/node_modules/smol-toml/dist/error.d.ts +38 -0
- package/node_modules/smol-toml/dist/error.js +63 -0
- package/node_modules/smol-toml/dist/extract.js +69 -0
- package/node_modules/smol-toml/dist/index.cjs +734 -0
- package/node_modules/smol-toml/dist/index.d.ts +43 -0
- package/node_modules/smol-toml/dist/index.js +33 -0
- package/node_modules/smol-toml/dist/parse.d.ts +36 -0
- package/node_modules/smol-toml/dist/parse.js +149 -0
- package/node_modules/smol-toml/dist/primitive.js +238 -0
- package/node_modules/smol-toml/dist/stringify.d.ts +31 -0
- package/node_modules/smol-toml/dist/stringify.js +181 -0
- package/node_modules/smol-toml/dist/struct.js +179 -0
- package/node_modules/smol-toml/dist/util.d.ts +38 -0
- package/node_modules/smol-toml/dist/util.js +89 -0
- package/node_modules/smol-toml/package.json +68 -0
- package/package.json +3 -3
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.d.ts +0 -27
- package/node_modules/@spexcode/spec-cli/dist/execution-trace.js +0 -475
- package/node_modules/@spexcode/spec-cli/dist/session-execution.d.ts +0 -10
- package/node_modules/@spexcode/spec-cli/dist/session-execution.js +0 -70
- package/node_modules/@spexcode/spec-cli/dist/transcript-reader.js +0 -251
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { parse as parseToml } from 'smol-toml';
|
|
2
3
|
import { join, dirname, basename } from 'node:path';
|
|
3
4
|
import { homedir, tmpdir } from 'node:os';
|
|
4
5
|
import { createHash, randomBytes } from 'node:crypto';
|
|
@@ -12,15 +13,14 @@ import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js'
|
|
|
12
13
|
import { claudeHeadlessColdRuntime, claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
|
|
13
14
|
import { codexHeadlessLaunchCommand } from './codex-headless.js';
|
|
14
15
|
import { opencodeHeadlessColdRuntime, opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
|
|
15
|
-
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
|
|
16
|
+
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, interruptPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
|
|
16
17
|
import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHome } from '@spexcode/spec-core';
|
|
17
18
|
import { git } from '@spexcode/spec-core';
|
|
18
19
|
import { shQuote } from './sh.js';
|
|
19
20
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
|
|
20
21
|
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession } from './codex-runtime-generations.js';
|
|
21
22
|
import { writeFileIfChanged } from './file-write.js';
|
|
22
|
-
import {
|
|
23
|
-
import { readClaudeTranscript, readCodexTranscript, unsupportedTranscriptReader } from './transcript-reader.js';
|
|
23
|
+
import { claudeTranscript, codexRolloutPath, codexTranscript, opencodeTranscript, piTranscript, unsupportedTranscript } from '@spexcode/transcript';
|
|
24
24
|
import { harnessIdentity, HARNESS_IDENTITIES } from '@spexcode/spec-core';
|
|
25
25
|
// One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
|
|
26
26
|
// once per call, then its result is joined to every record that names that adapter/thread. Product readers must
|
|
@@ -409,6 +409,83 @@ export async function deliverViaSocketOrWake(id, text, mid, coldWake, unprovenEr
|
|
|
409
409
|
return { ok: false, error: unprovenError };
|
|
410
410
|
return coldWake();
|
|
411
411
|
}
|
|
412
|
+
const RENDEZVOUS_INTERRUPT_WALL_MS = 10_000;
|
|
413
|
+
const RENDEZVOUS_INTERRUPT_SETTLE_MS = 15_000;
|
|
414
|
+
// @@@ interruptViaRendezvous - one `interrupt` line to the session's LIVE rendezvous listener, confirmed by the
|
|
415
|
+
// shim's own answer ([[shim-runtime]] runs the host's native abort — pi's ctx.abort(), opencode's
|
|
416
|
+
// session.abort — and writes interrupt-done / interrupt-rejected). Unlike a reply poke, whose durable copy is
|
|
417
|
+
// the timeline, an interrupt has nothing to fall back on: silence is a loud failure, never an optimistic ok.
|
|
418
|
+
// A generative shim serves its socket only while a turn process is alive, so a dead listener means no turn
|
|
419
|
+
// is running — nothing to interrupt — and an unproven probe sends nothing. `settle` additionally waits for
|
|
420
|
+
// that listener to go dead: for a one-turn-per-process adapter the abort ends the process, and confirming
|
|
421
|
+
// only then means a delivery that follows the interrupt wakes cold instead of poking an exiting agent.
|
|
422
|
+
export async function interruptViaRendezvous(id, harness, opts = {}) {
|
|
423
|
+
const probe = await rendezvousListening(id);
|
|
424
|
+
if (probe === 'dead')
|
|
425
|
+
return { ok: false, error: `no ${harness} turn is running for session ${id} - nothing to interrupt` };
|
|
426
|
+
if (probe === 'unproven')
|
|
427
|
+
return { ok: false, error: `${harness} rendezvous listener for session ${id} is unproven - interrupt NOT sent` };
|
|
428
|
+
const answered = await sendRendezvousInterrupt(id, harness);
|
|
429
|
+
if (!answered.ok || !opts.settle)
|
|
430
|
+
return answered;
|
|
431
|
+
const deadline = Date.now() + RENDEZVOUS_INTERRUPT_SETTLE_MS;
|
|
432
|
+
for (;;) {
|
|
433
|
+
const state = await rendezvousListening(id);
|
|
434
|
+
if (state === 'dead')
|
|
435
|
+
return { ok: true };
|
|
436
|
+
if (Date.now() >= deadline)
|
|
437
|
+
return { ok: false, error: `${harness} confirmed the interrupt but its turn process for session ${id} is still ${state === 'live' ? 'serving' : 'unproven'} after ${RENDEZVOUS_INTERRUPT_SETTLE_MS}ms` };
|
|
438
|
+
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function sendRendezvousInterrupt(id, harness) {
|
|
442
|
+
return new Promise((resolve) => {
|
|
443
|
+
let settled = false;
|
|
444
|
+
let c;
|
|
445
|
+
const done = (r) => {
|
|
446
|
+
if (settled)
|
|
447
|
+
return;
|
|
448
|
+
settled = true;
|
|
449
|
+
clearTimeout(wall);
|
|
450
|
+
try {
|
|
451
|
+
c?.destroy();
|
|
452
|
+
}
|
|
453
|
+
catch { /* */ }
|
|
454
|
+
resolve(r);
|
|
455
|
+
};
|
|
456
|
+
const wall = setTimeout(() => done({ ok: false, error: `${harness} did not confirm the interrupt for session ${id} within ${RENDEZVOUS_INTERRUPT_WALL_MS}ms (a shim materialized before interrupt support never answers - rerun spex materialize)` }), RENDEZVOUS_INTERRUPT_WALL_MS);
|
|
457
|
+
try {
|
|
458
|
+
c = createConnection({ path: rvSock(id) });
|
|
459
|
+
}
|
|
460
|
+
catch (e) {
|
|
461
|
+
done({ ok: false, error: `rendezvous socket connect threw: ${String(e)} - interrupt NOT sent` });
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
c.on('error', (e) => done({ ok: false, error: `rendezvous socket error: ${e?.code || String(e)} - interrupt NOT confirmed` }));
|
|
465
|
+
c.on('close', () => done({ ok: false, error: `rendezvous connection closed before ${harness} answered the interrupt` }));
|
|
466
|
+
c.on('connect', () => c.write(`${JSON.stringify({ type: 'interrupt' })}\n`));
|
|
467
|
+
let buf = '';
|
|
468
|
+
c.on('data', (chunk) => {
|
|
469
|
+
buf += chunk.toString('utf8');
|
|
470
|
+
let nl;
|
|
471
|
+
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
472
|
+
const line = buf.slice(0, nl);
|
|
473
|
+
buf = buf.slice(nl + 1);
|
|
474
|
+
let msg = {};
|
|
475
|
+
try {
|
|
476
|
+
msg = JSON.parse(line);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
if (msg.type === 'interrupt-done')
|
|
482
|
+
return done({ ok: true });
|
|
483
|
+
if (msg.type === 'interrupt-rejected')
|
|
484
|
+
return done({ ok: false, error: `${harness} rejected the interrupt: ${msg.error || 'no reason given'}` });
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
}
|
|
412
489
|
// The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
|
|
413
490
|
// 0.142.3 (`codex app-server generate-ts` → ClientRequest.ts / v2/*Params.ts): the visible TUI is launched with
|
|
414
491
|
// `codex --remote unix://<sock>`, so its thread is ALREADY loaded in this server — we must NOT `thread/resume`
|
|
@@ -592,8 +669,13 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
|
|
|
592
669
|
`if [ "$1" = "--resume" ]; then`,
|
|
593
670
|
` tid=$2`,
|
|
594
671
|
...(attachTui ? [] : [
|
|
595
|
-
// A headless
|
|
596
|
-
//
|
|
672
|
+
// A headless resume has no TUI to reload the thread. The shared app-server evicts an idle thread from its
|
|
673
|
+
// loaded set, and headless readiness proves online only for a RESIDENT thread, so resume must reopen it
|
|
674
|
+
// here — the load the visible TUI's `resume "$tid"` would have done — or readiness times out for a thread
|
|
675
|
+
// that is fine on disk. codex-generation-session above already ensured the app-server, so `$sock` is live.
|
|
676
|
+
` ${SPEX} internal codex-reopen "$sock" "$tid" || exit 1`,
|
|
677
|
+
// A headless forced reopen with NO thread id and no prompt has nothing to attach and nothing to launch.
|
|
678
|
+
// Keep it a no-op instead of calling codex-launch without a prompt (which would mint an unrelated thread).
|
|
597
679
|
`elif [ "$#" -eq 0 ]; then`,
|
|
598
680
|
` exit 0`,
|
|
599
681
|
]),
|
|
@@ -946,6 +1028,69 @@ const CODEX_MUTATION_CENSUS_MS = 15_000;
|
|
|
946
1028
|
const CODEX_MUTATION_BASE_MS = 15_000;
|
|
947
1029
|
const CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS = 1000;
|
|
948
1030
|
const codexArchiveBudgetMs = (bytes) => CODEX_MUTATION_BASE_MS + Math.ceil(bytes / CODEX_ARCHIVE_FLUSH_FLOOR_BYTES_PER_MS);
|
|
1031
|
+
// @@@ codexReopenThread - load an evicted thread back into the shared app-server WITHOUT running a turn. The
|
|
1032
|
+
// app-server evicts an idle thread from its in-memory loaded set, and codex-headless readiness/liveness proves
|
|
1033
|
+
// online only when the thread is resident (thread/loaded/list). A visible-TUI resume reloads it implicitly via
|
|
1034
|
+
// `resume "$tid"`; headless has no TUI, so its resume must issue this `thread/resume` itself or readiness times
|
|
1035
|
+
// out for a thread that is perfectly fine on disk. `excludeTurns` + a one-row page keeps a huge rollout from
|
|
1036
|
+
// streaming back — we only need the load, not the history. Idempotent: reopening an already-loaded thread is a
|
|
1037
|
+
// no-op the server answers at once.
|
|
1038
|
+
export function codexReopenThread(sock, threadId, budgetMs = 20_000) {
|
|
1039
|
+
return new Promise((resolve) => {
|
|
1040
|
+
const conn = createConnection(sock);
|
|
1041
|
+
const fs = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) };
|
|
1042
|
+
let upgraded = false, settled = false;
|
|
1043
|
+
const done = (r) => {
|
|
1044
|
+
if (settled)
|
|
1045
|
+
return;
|
|
1046
|
+
settled = true;
|
|
1047
|
+
clearTimeout(timer);
|
|
1048
|
+
try {
|
|
1049
|
+
conn.destroy();
|
|
1050
|
+
}
|
|
1051
|
+
catch { /* */ }
|
|
1052
|
+
resolve(r);
|
|
1053
|
+
};
|
|
1054
|
+
const timer = setTimeout(() => done({ ok: false, error: `codex app-server did not reopen thread ${threadId} within ${budgetMs}ms` }), budgetMs);
|
|
1055
|
+
conn.on('error', (e) => done({ ok: false, error: `codex app-server connection failed: ${rpcError(e)}` }));
|
|
1056
|
+
conn.on('close', () => done({ ok: false, error: 'codex app-server closed before thread/resume was answered' }));
|
|
1057
|
+
const send = (m) => conn.write(wsText(JSON.stringify(m)));
|
|
1058
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))));
|
|
1059
|
+
const handle = (json) => {
|
|
1060
|
+
let m;
|
|
1061
|
+
try {
|
|
1062
|
+
m = JSON.parse(json);
|
|
1063
|
+
}
|
|
1064
|
+
catch {
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (m.error)
|
|
1068
|
+
return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` });
|
|
1069
|
+
if (m.id === 1 && m.result) {
|
|
1070
|
+
send({ method: 'initialized', params: {} });
|
|
1071
|
+
return send({ id: 2, method: 'thread/resume', params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } } });
|
|
1072
|
+
}
|
|
1073
|
+
if (m.id === 2 && m.result)
|
|
1074
|
+
return done({ ok: true });
|
|
1075
|
+
};
|
|
1076
|
+
conn.on('data', (chunk) => {
|
|
1077
|
+
fs.buf = Buffer.concat([fs.buf, chunk]);
|
|
1078
|
+
if (!upgraded) {
|
|
1079
|
+
const i = fs.buf.indexOf('\r\n\r\n');
|
|
1080
|
+
if (i < 0)
|
|
1081
|
+
return;
|
|
1082
|
+
const head = fs.buf.slice(0, i).toString('utf8');
|
|
1083
|
+
if (!/^HTTP\/1\.1 101/.test(head))
|
|
1084
|
+
return done({ ok: false, error: 'codex app-server refused WebSocket upgrade for thread/resume' });
|
|
1085
|
+
upgraded = true;
|
|
1086
|
+
fs.buf = fs.buf.slice(i + 4);
|
|
1087
|
+
send(wsInitialize);
|
|
1088
|
+
}
|
|
1089
|
+
if (drainWsFrames(fs, conn, handle))
|
|
1090
|
+
done({ ok: false, error: 'codex app-server closed during thread/resume' });
|
|
1091
|
+
});
|
|
1092
|
+
});
|
|
1093
|
+
}
|
|
949
1094
|
// Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
|
|
950
1095
|
// entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
|
|
951
1096
|
// supplies every protocol source kind explicitly for its thread/list calls.
|
|
@@ -2413,6 +2558,18 @@ function stripCodexTrustFor(cur, proj, hooksJson) {
|
|
|
2413
2558
|
}
|
|
2414
2559
|
return out.join('\n').replace(/\n{3,}/g, '\n\n').replace(/^\n+/, '').replace(/\n*$/, '');
|
|
2415
2560
|
}
|
|
2561
|
+
// @@@ assertCodexConfigParses - ~/.codex/config.toml has TWO uncoordinated writers, codex itself and this adapter,
|
|
2562
|
+
// and neither rewrite is something the other waits for. A body read mid-write (a line cut short) or one already
|
|
2563
|
+
// broken must never be written back: codex refuses the whole file, and every dispatched thread then dies at
|
|
2564
|
+
// config load. The check is on the bytes about to land, so stripping this project's own duplicate still heals.
|
|
2565
|
+
function assertCodexConfigParses(file, body) {
|
|
2566
|
+
try {
|
|
2567
|
+
parseToml(body);
|
|
2568
|
+
}
|
|
2569
|
+
catch (error) {
|
|
2570
|
+
throw new Error(`refusing to rewrite ${file}: it does not parse as TOML (${error instanceof Error ? error.message : String(error)}); the file is broken or another writer is mid-write — repair it, then rerun`);
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2416
2573
|
// additively stamp PROJECT trust (`[projects."<proj>"] trust_level = "trusted"`) AND the per-hook
|
|
2417
2574
|
// `trusted_hash` blocks for each event into the user's GLOBAL ~/.codex/config.toml, so a dispatched or
|
|
2418
2575
|
// self-launched codex trusts THIS project's config layer (enabling hook discovery) AND treats each hook as
|
|
@@ -2432,9 +2589,11 @@ export function writeCodexTrust(proj, events, cmdFor) {
|
|
|
2432
2589
|
}
|
|
2433
2590
|
const blk = `# spexcode:trust:${proj} (managed — do not edit)\n${lines.join('\n')}\n# spexcode:trust:end:${proj}`;
|
|
2434
2591
|
const cleaned = stripCodexTrustFor(existsSync(file) ? readFileSync(file, 'utf8') : '', proj, hooksJson);
|
|
2592
|
+
const next = cleaned ? `${cleaned}\n\n${blk}\n` : `${blk}\n`;
|
|
2593
|
+
assertCodexConfigParses(file, next);
|
|
2435
2594
|
if (!existsSync(home))
|
|
2436
2595
|
mkdirSync(home, { recursive: true });
|
|
2437
|
-
writeFileIfChanged(file,
|
|
2596
|
+
writeFileIfChanged(file, next);
|
|
2438
2597
|
return file;
|
|
2439
2598
|
}
|
|
2440
2599
|
// the inverse of writeCodexTrust: strip THIS project's codex trust from the GLOBAL config.toml — the SAME
|
|
@@ -2452,7 +2611,9 @@ function removeCodexTrust(proj) {
|
|
|
2452
2611
|
!cur.includes(`# spexcode:trust:${proj} `) && !cur.includes(`# spexcode:trust:end:${proj}`))
|
|
2453
2612
|
return;
|
|
2454
2613
|
const cleaned = stripCodexTrustFor(cur, proj, hooksJson);
|
|
2455
|
-
|
|
2614
|
+
const next = cleaned ? `${cleaned}\n` : '';
|
|
2615
|
+
assertCodexConfigParses(file, next);
|
|
2616
|
+
writeFileIfChanged(file, next);
|
|
2456
2617
|
}
|
|
2457
2618
|
// is this file git-tracked in proj? (guards cleanHarness's deleteIfEmpty; env-stripped git, never throws)
|
|
2458
2619
|
function isTrackedFile(proj, f) {
|
|
@@ -2671,10 +2832,9 @@ export const claudeHarness = {
|
|
|
2671
2832
|
dispatchId: 'claude',
|
|
2672
2833
|
headless: false,
|
|
2673
2834
|
events: CLAUDE_EVENTS,
|
|
2674
|
-
ownsRendezvous: true, //
|
|
2835
|
+
ownsRendezvous: true, // claude's background daemon opens the rendezvous control socket (prompt delivery + liveness)
|
|
2675
2836
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
2676
|
-
|
|
2677
|
-
readTranscript: readClaudeTranscript,
|
|
2837
|
+
transcript: claudeTranscript,
|
|
2678
2838
|
launchCmd: (_id, _rt, cmd) => claudeBaseCmd(cmd), // claude's full invocation IS its base command (the tail is appended by the caller)
|
|
2679
2839
|
baseCmd: claudeBaseCmd,
|
|
2680
2840
|
oneShotTurn: (prompt, cmd) => ({ command: `${claudeBaseCmd(cmd)} -p`, stdin: prompt }), // --print reads the prompt from stdin
|
|
@@ -2792,10 +2952,9 @@ export const codexHarness = {
|
|
|
2792
2952
|
headless: false,
|
|
2793
2953
|
sharedRuntimeSpawn: true,
|
|
2794
2954
|
events: CODEX_EVENTS,
|
|
2795
|
-
ownsRendezvous: false, // no
|
|
2955
|
+
ownsRendezvous: false, // no rendezvous daemon — liveness + prompts through the project app-server socket
|
|
2796
2956
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
2797
|
-
|
|
2798
|
-
readTranscript: readCodexTranscript,
|
|
2957
|
+
transcript: codexTranscript,
|
|
2799
2958
|
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
|
|
2800
2959
|
baseCmd: codexBaseCmd,
|
|
2801
2960
|
oneShotTurn: (prompt, cmd) => ({ command: `${codexBaseCmd(cmd)} exec -`, stdin: prompt }), // `exec -` reads the prompt from stdin
|
|
@@ -3094,11 +3253,13 @@ async function codexHeadlessReadinessProof(current) {
|
|
|
3094
3253
|
return null;
|
|
3095
3254
|
const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
|
|
3096
3255
|
.find((candidate) => candidate.key === codexDescriptorKey(endpoint));
|
|
3097
|
-
if (!descriptor
|
|
3256
|
+
if (!descriptor)
|
|
3098
3257
|
return null;
|
|
3099
3258
|
const generationBefore = codexRuntimeGenerationProof(record.runtimeDir, endpoint);
|
|
3100
3259
|
if (!generationBefore)
|
|
3101
3260
|
return null;
|
|
3261
|
+
if (!descriptor.residency)
|
|
3262
|
+
return null;
|
|
3102
3263
|
let resident;
|
|
3103
3264
|
try {
|
|
3104
3265
|
resident = await descriptor.residency();
|
|
@@ -3179,7 +3340,7 @@ export const codexHeadlessHarness = {
|
|
|
3179
3340
|
// hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
|
|
3180
3341
|
// extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
|
|
3181
3342
|
// dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
|
|
3182
|
-
// CLAUDE_BG_RENDEZVOUS_SOCK) speaking
|
|
3343
|
+
// CLAUDE_BG_RENDEZVOUS_SOCK) speaking claude's rendezvous line protocol — so
|
|
3183
3344
|
// deliverViaRendezvous and the socket-listener liveness work through the same adapter seam. Trust: pi gates project-local
|
|
3184
3345
|
// extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
|
|
3185
3346
|
// checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
|
|
@@ -3189,10 +3350,9 @@ export const piHarness = {
|
|
|
3189
3350
|
dispatchId: 'pi',
|
|
3190
3351
|
headless: false,
|
|
3191
3352
|
events: PI_EVENTS,
|
|
3192
|
-
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks
|
|
3353
|
+
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks claude's rendezvous protocol
|
|
3193
3354
|
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
3194
|
-
|
|
3195
|
-
readTranscript: (threadId, range) => unsupportedTranscriptReader('pi', threadId, range),
|
|
3355
|
+
transcript: piTranscript,
|
|
3196
3356
|
launchCmd: (_id, _rt, cmd) => `${piBaseCmd(cmd)} --approve`, // --approve = one-run project trust (belt to writeTrust's braces)
|
|
3197
3357
|
baseCmd: piBaseCmd,
|
|
3198
3358
|
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
@@ -3242,6 +3402,8 @@ export const piHeadlessHarness = {
|
|
|
3242
3402
|
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
3243
3403
|
liveness: sessionHomeLiveness,
|
|
3244
3404
|
deliver: deliverViaPiHeadless,
|
|
3405
|
+
// the controller aborts its own turn child natively and confirms only once that child is gone
|
|
3406
|
+
interrupt: interruptPiHeadless,
|
|
3245
3407
|
cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
|
|
3246
3408
|
coldRuntime: async (rec) => {
|
|
3247
3409
|
const result = await piHeadlessColdRuntime(rec);
|
|
@@ -3262,8 +3424,7 @@ export const zcodeHarness = {
|
|
|
3262
3424
|
events: ZCODE_EVENTS,
|
|
3263
3425
|
ownsRendezvous: false,
|
|
3264
3426
|
paneTitleIsSelfSummary: false,
|
|
3265
|
-
|
|
3266
|
-
readTranscript: (threadId, range) => unsupportedTranscriptReader('zcode', threadId, range),
|
|
3427
|
+
transcript: unsupportedTranscript('zcode'),
|
|
3267
3428
|
launchCmd: (_id, _rt, cmd) => `${zcodeBaseCmd(cmd)} --prompt`,
|
|
3268
3429
|
baseCmd: zcodeBaseCmd,
|
|
3269
3430
|
// z-code's one-turn launcher already IS the non-interactive shape; it takes the prompt as an argument.
|
|
@@ -3299,8 +3460,7 @@ export const opencodeHarness = {
|
|
|
3299
3460
|
// socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
|
|
3300
3461
|
ownsRendezvous: true,
|
|
3301
3462
|
paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
|
|
3302
|
-
|
|
3303
|
-
readTranscript: (threadId, range) => unsupportedTranscriptReader('opencode', threadId, range),
|
|
3463
|
+
transcript: opencodeTranscript,
|
|
3304
3464
|
launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
|
|
3305
3465
|
baseCmd: opencodeBaseCmd,
|
|
3306
3466
|
// `opencode run` takes the message positionally; it documents no stdin form, so the prompt is an argument.
|
|
@@ -3361,6 +3521,9 @@ export const opencodeHeadlessHarness = {
|
|
|
3361
3521
|
deliver: async (rec, text) => {
|
|
3362
3522
|
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`);
|
|
3363
3523
|
},
|
|
3524
|
+
// `opencode run` serves the rendezvous socket only for the turn it runs, so the plugin's session abort IS the
|
|
3525
|
+
// interrupt, and the listener going dead is the turn's exit — confirmed only then, so the next delivery wakes cold
|
|
3526
|
+
interrupt: (rec) => interruptViaRendezvous(rec.session, 'opencode-headless', { settle: true }),
|
|
3364
3527
|
};
|
|
3365
3528
|
// every adapter — materialize iterates this to write each harness's artifacts in one pass.
|
|
3366
3529
|
export const HARNESSES = [claudeHarness, codexHarness, opencodeHarness, piHarness, zcodeHarness, claudeHeadlessHarness, opencodeHeadlessHarness, piHeadlessHarness, codexHeadlessHarness];
|
|
@@ -3390,12 +3553,6 @@ export function launcherList(root = mainCheckout()) {
|
|
|
3390
3553
|
})
|
|
3391
3554
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
3392
3555
|
}
|
|
3393
|
-
// The dashboard's visibility projection. It never removes a launcher from the complete config/CLI path;
|
|
3394
|
-
// it only narrows GET /api/settings for the New Session picker ([[launcher-visibility]]).
|
|
3395
|
-
export function dashboardLauncherList(root = mainCheckout()) {
|
|
3396
|
-
const showHeadless = readConfig(root).dashboard?.showHeadlessLaunchers === true;
|
|
3397
|
-
return launcherList(root).filter((launcher) => showHeadless || !launcher.headless);
|
|
3398
|
-
}
|
|
3399
3556
|
export const MISSING_DEFAULT_LAUNCHER_ERROR = 'sessions.defaultLauncher is required for a launch without --launcher; set it in spexcode.json or spexcode.local.json (for example {"sessions":{"defaultLauncher":"claude"}})';
|
|
3400
3557
|
// the configured default launcher NAME ([[launcher-select]]) — the profile `spex session new`/a dropdown pick with no
|
|
3401
3558
|
// explicit choice resolves. Missing config is a fail-loud setup error, never an implicit fallthrough to a
|
|
@@ -506,7 +506,11 @@ function readBody(req) {
|
|
|
506
506
|
export function startHostDashboard(opts) {
|
|
507
507
|
const distDir = opts.distDir ?? resolveDistDir();
|
|
508
508
|
const peers = new MachinePeerGateway();
|
|
509
|
-
|
|
509
|
+
// a refused control socket is reclaimed inside start(); only a LIVE owner rejects, and that is fatal for a host
|
|
510
|
+
void peers.start().catch((error) => {
|
|
511
|
+
console.error(`spex dashboard: ${error instanceof Error ? error.message : String(error)}`);
|
|
512
|
+
process.exit(1);
|
|
513
|
+
});
|
|
510
514
|
const sseClients = new Set();
|
|
511
515
|
let lastBroadcast = '';
|
|
512
516
|
// reconcile → push the fresh list to every /projects/stream subscriber when it changed.
|
|
@@ -7,7 +7,7 @@ import { Hono } from 'hono';
|
|
|
7
7
|
import { cors } from 'hono/cors';
|
|
8
8
|
import { etag } from 'hono/etag';
|
|
9
9
|
import { createNodeWebSocket } from '@hono/node-ws';
|
|
10
|
-
import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig,
|
|
10
|
+
import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig, runtimeRoot } from '@spexcode/spec-core';
|
|
11
11
|
import { issuesEnabled, resolveRemark, retractRemark } from './localIssues.js';
|
|
12
12
|
import { closeIssue, createIssue, findIssue, mergedIssues, promote } from './issues.js';
|
|
13
13
|
import { remarkWithLoopIn, replyIssueWithLoopIn } from './loop-in.js';
|
|
@@ -21,10 +21,9 @@ import { gitA, gitTry, repoRoot } from '@spexcode/spec-core';
|
|
|
21
21
|
import { cockpitReview } from './cockpit.js';
|
|
22
22
|
import { EMPTY_PROMPT_ERROR, retractDiffComment, listSessions, listArchivedSessionIndex, sendText, drainSession, markHumanPromptActive, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, linkZCodeChildSession, projectCreatedSession, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, startWorktreeTrashReaper, SessionRecordUnusable, TMUX_SOCK, sessionDiff, saveDiffComment, sendDiffComments, canonicalWatchRecipients } from './sessions.js';
|
|
23
23
|
import { readTimeline } from './session-timeline.js';
|
|
24
|
-
import {
|
|
25
|
-
import { defaultHarness, HARNESSES, codexHarness,
|
|
24
|
+
import { readSessionTranscript, readSessionTranscriptTool, sessionTranscriptStream } from './session-transcript.js';
|
|
25
|
+
import { defaultHarness, HARNESSES, codexHarness, launcherList, launcherDefault } from './harness.js';
|
|
26
26
|
import { ensureCodexGenerationLedger, reclaimDrainingCodexGenerations } from './codex-runtime-generations.js';
|
|
27
|
-
import { TranscriptReadError } from './transcript-reader.js';
|
|
28
27
|
import { readBlobByHash } from '@spexcode/spec-eval/evaltab';
|
|
29
28
|
import { putBlob } from '@spexcode/spec-eval/cache';
|
|
30
29
|
import { fileHumanReading } from '@spexcode/spec-eval/filing';
|
|
@@ -299,11 +298,10 @@ app.post('/api/evidence', async (c) => {
|
|
|
299
298
|
});
|
|
300
299
|
// the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
|
|
301
300
|
// `layout` (resolveLayout()'s main/worktrees/branch shape — the write-guard's project-identity probe reads
|
|
302
|
-
// `.layout.main`) and the
|
|
303
|
-
//
|
|
301
|
+
// `.layout.main`) and the complete launcher profiles ([[launcher-select]]) the New-Session picker offers —
|
|
302
|
+
// `{ name, harness, cmd, headless }`: the cmd is read-only display data for the picker (the dashboard sits
|
|
304
303
|
// behind the gateway auth; the browser can read but never edit config) — plus the configured `default` NAME
|
|
305
|
-
// so the picker pre-selects the SAME launcher a bare `spex session new` uses
|
|
306
|
-
// its first visible row rather than a hidden headless default,
|
|
304
|
+
// so the picker pre-selects the SAME launcher a bare `spex session new` uses, else its first row.
|
|
307
305
|
// Missing defaultLauncher is returned as an actionable config error, not hidden by falling through to the
|
|
308
306
|
// built-in `claude` launcher.
|
|
309
307
|
// `tmuxSocket` is the `-L <name>` label our private tmux server runs under (a backend fact, env-overridable),
|
|
@@ -311,7 +309,7 @@ app.post('/api/evidence', async (c) => {
|
|
|
311
309
|
// beside the blessed `spex session attach` command — the frontend never hardcodes the socket.
|
|
312
310
|
app.get('/api/settings', async (c) => c.json({
|
|
313
311
|
layout: await resolveLayout(),
|
|
314
|
-
launchers:
|
|
312
|
+
launchers: launcherList(),
|
|
315
313
|
tmuxSocket: TMUX_SOCK,
|
|
316
314
|
...launcherDefault(),
|
|
317
315
|
}));
|
|
@@ -784,60 +782,12 @@ app.get('/api/sessions/:id/capture', async (c) => {
|
|
|
784
782
|
return c.text('session offline (no live pane)', 409);
|
|
785
783
|
return c.text('capture failed', 502);
|
|
786
784
|
});
|
|
787
|
-
//
|
|
788
|
-
//
|
|
789
|
-
//
|
|
790
|
-
app.get('/api/sessions/:id/
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
});
|
|
794
|
-
app.get('/api/sessions/:id/execution/stream', (c) => sessionExecutionStream(c));
|
|
795
|
-
// The native transcript is a bounded payload behind the durable timeline index. Bounds are explicit epoch
|
|
796
|
-
// milliseconds so the route never guesses which status interval the caller intended.
|
|
797
|
-
app.get('/api/sessions/:id/transcript', async (c) => {
|
|
798
|
-
const id = c.req.param('id') || '';
|
|
799
|
-
const fromRaw = c.req.query('from');
|
|
800
|
-
const toRaw = c.req.query('to');
|
|
801
|
-
if (fromRaw == null || toRaw == null || fromRaw === '' || toRaw === '')
|
|
802
|
-
return c.json({ error: 'transcript needs both from and to epoch milliseconds' }, 400);
|
|
803
|
-
const from = Number(fromRaw);
|
|
804
|
-
const to = Number(toRaw);
|
|
805
|
-
if (!Number.isFinite(from) || !Number.isFinite(to) || !Number.isInteger(from) || !Number.isInteger(to) || from >= to)
|
|
806
|
-
return c.json({ error: 'transcript interval is invalid: from and to must be integer epoch milliseconds with from < to' }, 400);
|
|
807
|
-
let raw;
|
|
808
|
-
try {
|
|
809
|
-
raw = readAliasedRawRecord(id);
|
|
810
|
-
}
|
|
811
|
-
catch (error) {
|
|
812
|
-
return c.json({ error: `session ${id} record is unreadable: ${error instanceof Error ? error.message : String(error)}` }, 500);
|
|
813
|
-
}
|
|
814
|
-
if (!raw || !raw.governed)
|
|
815
|
-
return c.json({ error: `session ${id} does not exist` }, 404);
|
|
816
|
-
let harness;
|
|
817
|
-
try {
|
|
818
|
-
harness = harnessById(typeof raw.harness === 'string' && raw.harness ? raw.harness : defaultHarness.id);
|
|
819
|
-
}
|
|
820
|
-
catch (error) {
|
|
821
|
-
return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
|
|
822
|
-
}
|
|
823
|
-
const threadId = harness.exactNativeTargetId({
|
|
824
|
-
session: raw.session_id,
|
|
825
|
-
harnessSessionId: typeof raw.harness_session_id === 'string' ? raw.harness_session_id : null,
|
|
826
|
-
stopped: !!raw.stopped,
|
|
827
|
-
archived: !!raw.archived,
|
|
828
|
-
});
|
|
829
|
-
if (!threadId)
|
|
830
|
-
return c.json({ error: `session ${id} transcript is unavailable: native harness identity is missing` }, 409);
|
|
831
|
-
try {
|
|
832
|
-
return c.json(await harness.readTranscript(threadId, { from, to }));
|
|
833
|
-
}
|
|
834
|
-
catch (error) {
|
|
835
|
-
if (!(error instanceof TranscriptReadError))
|
|
836
|
-
throw error;
|
|
837
|
-
const status = error.reason === 'unsupported' ? 501 : error.reason === 'invalid' ? 422 : 409;
|
|
838
|
-
return c.json({ error: error.message, reason: error.reason }, status);
|
|
839
|
-
}
|
|
840
|
-
});
|
|
785
|
+
// The native transcript behind the durable timeline index ([[transcript-reader]], [[session-transcript]]):
|
|
786
|
+
// a closed interval by GET, the open working interval by SSE. Bounds are explicit epoch milliseconds so the
|
|
787
|
+
// route never guesses which stretch the caller intended; native bytes stay behind the adapter.
|
|
788
|
+
app.get('/api/sessions/:id/transcript', (c) => readSessionTranscript(c));
|
|
789
|
+
app.get('/api/sessions/:id/transcript/stream', (c) => sessionTranscriptStream(c));
|
|
790
|
+
app.get('/api/sessions/:id/transcript/tool/:toolId', (c) => readSessionTranscriptTool(c));
|
|
841
791
|
// the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
|
|
842
792
|
// FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
|
|
843
793
|
// the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
|
|
@@ -1045,8 +995,14 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
1045
995
|
return c.json({ ok: false, error: EMPTY_PROMPT_ERROR }, 400);
|
|
1046
996
|
const deliveryKey = typeof body?.deliveryId === 'string' && body.deliveryId.trim() ? body.deliveryId.trim() : undefined;
|
|
1047
997
|
// Command Box acceptance is the durable append. Do not hold the HTTP request on a
|
|
1048
|
-
// slow native handoff: the delivery supervisor already owns the queued retry path.
|
|
1049
|
-
|
|
998
|
+
// slow native handoff: the delivery supervisor already owns the queued retry path. The Conversation
|
|
999
|
+
// footer is a Command Box on a terminal-free surface, so it says `replyVia:"note"` here exactly as a
|
|
1000
|
+
// text send would, and the note-reply insert rides the same delivery.
|
|
1001
|
+
const r = await sendText(id, text, undefined, {
|
|
1002
|
+
deferDrain: true,
|
|
1003
|
+
...(deliveryKey ? { deliveryKey } : {}),
|
|
1004
|
+
...(body?.replyVia === 'note' ? { replyVia: 'note' } : {}),
|
|
1005
|
+
});
|
|
1050
1006
|
if (!r.ok)
|
|
1051
1007
|
return c.json(r, 502);
|
|
1052
1008
|
// Start the first handoff without holding the HTTP response on native readiness. The queue remains the
|
|
@@ -51,8 +51,9 @@ export declare class MachinePeerGateway {
|
|
|
51
51
|
private control;
|
|
52
52
|
private retry;
|
|
53
53
|
private closing;
|
|
54
|
-
start(): void
|
|
54
|
+
start(): Promise<void>;
|
|
55
55
|
private startControl;
|
|
56
|
+
private listenControl;
|
|
56
57
|
private handleRpc;
|
|
57
58
|
private startInbound;
|
|
58
59
|
private stopInbound;
|
|
@@ -72,13 +72,17 @@ export function readPeerMachineId() {
|
|
|
72
72
|
writeStore({ ...store, machineId });
|
|
73
73
|
return machineId;
|
|
74
74
|
}
|
|
75
|
+
// @@@ peer sender spelling - the authenticated peer identity travels as the message's ordinary `senderSessionId`,
|
|
76
|
+
// so it must fit the protocol's frozen session_id grammar `[0-9A-Za-z_][0-9A-Za-z_-]*` (session-protocol §5.2:
|
|
77
|
+
// no `:`, no leading `-`; namespaces are encoded INTO the id). `peer_<machineId>_<sessionId>` is that encoding;
|
|
78
|
+
// the earlier `peer:` spelling was refused by every receiving backend with PROTOCOL_SESSION_ID_INVALID.
|
|
75
79
|
export function peerSenderRef(machineId, sessionId) {
|
|
76
|
-
return sessionId && validMachineId(sessionId) ? `
|
|
80
|
+
return sessionId && validMachineId(sessionId) ? `peer_${machineId}_${sessionId}` : `peer_${machineId}`;
|
|
77
81
|
}
|
|
78
82
|
function validPeerSender(value, machineId) {
|
|
79
83
|
if (typeof value !== 'string')
|
|
80
84
|
return false;
|
|
81
|
-
const match = value.match(/^
|
|
85
|
+
const match = value.match(/^peer_([0-9a-f-]{36})(?:_([0-9a-f-]{36}))?$/i);
|
|
82
86
|
return !!match && match[1] === machineId && (match[2] === undefined || validMachineId(match[2]));
|
|
83
87
|
}
|
|
84
88
|
export function findMachinePeer(sshAddress) {
|
|
@@ -367,34 +371,61 @@ function sshArgs(peer) {
|
|
|
367
371
|
'--', peer.sshAddress,
|
|
368
372
|
];
|
|
369
373
|
}
|
|
374
|
+
// true iff something ACCEPTS a connection at this unix path; any error (ECONNREFUSED for a leftover path, ENOENT
|
|
375
|
+
// for a vanished one) means no live listener.
|
|
376
|
+
function probeUnixListener(path) {
|
|
377
|
+
return new Promise((resolve) => {
|
|
378
|
+
const socket = createConnection(path);
|
|
379
|
+
socket.once('connect', () => { socket.destroy(); resolve(true); });
|
|
380
|
+
socket.once('error', () => resolve(false));
|
|
381
|
+
});
|
|
382
|
+
}
|
|
370
383
|
export class MachinePeerGateway {
|
|
371
384
|
inbound = new Map();
|
|
372
385
|
children = new Map();
|
|
373
386
|
control = null;
|
|
374
387
|
retry = null;
|
|
375
388
|
closing = false;
|
|
389
|
+
// start() resolves once the control socket is claimed. With no leftover path the claim is synchronous (the
|
|
390
|
+
// listener is issued before the promise settles, so callers may issue control RPCs right away); a leftover path
|
|
391
|
+
// is probed first, which is the only asynchronous branch.
|
|
376
392
|
start() {
|
|
377
393
|
for (const peer of listMachinePeers())
|
|
378
394
|
this.startInbound(peer);
|
|
379
|
-
this.startControl()
|
|
380
|
-
for (const peer of listMachinePeers())
|
|
381
|
-
if (peer.owner)
|
|
382
|
-
this.ensureTunnel(peer);
|
|
383
|
-
this.retry = setInterval(() => {
|
|
395
|
+
return this.startControl().then(() => {
|
|
384
396
|
for (const peer of listMachinePeers())
|
|
385
|
-
if (peer.owner
|
|
397
|
+
if (peer.owner)
|
|
386
398
|
this.ensureTunnel(peer);
|
|
387
|
-
|
|
388
|
-
|
|
399
|
+
this.retry = setInterval(() => {
|
|
400
|
+
for (const peer of listMachinePeers())
|
|
401
|
+
if (peer.owner && peer.state === 'connecting')
|
|
402
|
+
this.ensureTunnel(peer);
|
|
403
|
+
}, PEER_RETRY_MS);
|
|
404
|
+
this.retry.unref();
|
|
405
|
+
});
|
|
389
406
|
}
|
|
407
|
+
// @@@ stale control socket - a killed gateway never unlinks peer.sock, so the FILE proves nothing about
|
|
408
|
+
// ownership; only a connect does. A live gateway accepts the connection; a leftover path refuses it
|
|
409
|
+
// (ECONNREFUSED). So a missing path is claimed at once, a refusing path is unlinked and reclaimed, and only an
|
|
410
|
+
// accepting listener is "another `spex dashboard`" — the same test claude-rendezvous applies to its socket file.
|
|
390
411
|
startControl() {
|
|
391
412
|
const path = peerSocketPath();
|
|
392
413
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
393
|
-
if (existsSync(path)) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
throw error;
|
|
414
|
+
if (!existsSync(path)) {
|
|
415
|
+
this.listenControl(path);
|
|
416
|
+
return Promise.resolve();
|
|
397
417
|
}
|
|
418
|
+
return probeUnixListener(path).then((live) => {
|
|
419
|
+
if (live) {
|
|
420
|
+
const error = new Error(`machine peer service already owns ${path} — another \`spex dashboard\` is running`);
|
|
421
|
+
error.name = 'BackendError';
|
|
422
|
+
throw error;
|
|
423
|
+
}
|
|
424
|
+
rmSync(path, { force: true });
|
|
425
|
+
this.listenControl(path);
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
listenControl(path) {
|
|
398
429
|
this.control = createNetServer({ allowHalfOpen: true }, (socket) => {
|
|
399
430
|
let input = '';
|
|
400
431
|
socket.setEncoding('utf8');
|
|
@@ -89,8 +89,16 @@ export const SpexcodePlugin = async (ctx) => {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
// The shared rendezvous server receives best-effort reply pokes. An unadopted plugin simply leaves the
|
|
92
|
-
// timeline line for its next turn boundary; the SDK injection remains host-owned.
|
|
93
|
-
|
|
92
|
+
// timeline line for its next turn boundary; the SDK injection remains host-owned. An interrupt is
|
|
93
|
+
// opencode's own session abort on the adopted root session, confirmed back over the same socket.
|
|
94
|
+
rt.serveRendezvous(injectPrompt, {
|
|
95
|
+
canInject: () => !!(client && rootSession),
|
|
96
|
+
interrupt: async () => {
|
|
97
|
+
if (!client || !rootSession) throw new Error("no opencode session is adopted - nothing to interrupt")
|
|
98
|
+
const r = await client.session.abort({ path: { id: rootSession } })
|
|
99
|
+
if (r === false || (r && r.data === false)) throw new Error("no opencode turn is running - nothing to interrupt")
|
|
100
|
+
},
|
|
101
|
+
})
|
|
94
102
|
|
|
95
103
|
const toolPayload = (input, output) => {
|
|
96
104
|
const sid = (input && input.sessionID) || ""
|