@spexcode/spec-cli 0.6.5 → 0.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/spex.mjs +2 -1
- package/dist/claude-headless.d.ts +4 -1
- package/dist/claude-headless.js +13 -4
- package/dist/cli.js +72 -23
- package/dist/client.d.ts +2 -1
- package/dist/client.js +13 -8
- package/dist/codex-runtime-generations.d.ts +5 -0
- package/dist/codex-runtime-generations.js +112 -0
- package/dist/doctor.js +7 -1
- package/dist/gateway-hub.js +7 -5
- package/dist/gateway.d.ts +1 -0
- package/dist/gateway.js +44 -20
- package/dist/graphCache.js +2 -1
- package/dist/graphSnapshot.js +2 -1
- package/dist/harness.d.ts +15 -2
- package/dist/harness.js +174 -54
- package/dist/help.d.ts +5 -0
- package/dist/help.js +26 -6
- package/dist/index.js +3 -3
- package/dist/listen.d.ts +2 -1
- package/dist/listen.js +10 -10
- package/dist/opencode-headless.d.ts +1 -0
- package/dist/opencode-headless.js +7 -0
- package/dist/runtime-rotate.d.ts +1 -0
- package/dist/runtime-rotate.js +58 -0
- package/dist/session-follow.js +1 -1
- package/dist/session-timeline.d.ts +6 -46
- package/dist/session-timeline.js +8 -221
- package/dist/sessions.d.ts +25 -4
- package/dist/sessions.js +822 -394
- package/dist/supervise.js +3 -3
- package/package.json +6 -4
- package/dist/delivery-queue.d.ts +0 -23
- package/dist/delivery-queue.js +0 -179
- package/dist/session-cursors.d.ts +0 -14
- package/dist/session-cursors.js +0 -82
package/dist/gateway.js
CHANGED
|
@@ -178,17 +178,15 @@ export function startGateway(opts) {
|
|
|
178
178
|
// narrows the public gateway's reach. The gate note keys on LOOPBACK, not on host-being-explicit:
|
|
179
179
|
// an ungated loopback bind is normal, an ungated wide bind is announced — never silent.
|
|
180
180
|
const isLoopback = opts.host === '127.0.0.1' || opts.host === 'localhost' || opts.host === '::1';
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
console.log('[gateway] (TLS off — --http)');
|
|
188
|
-
};
|
|
181
|
+
const scheme = secure ? 'https' : 'http';
|
|
182
|
+
const label = opts.label ?? 'public mode';
|
|
183
|
+
const gate = isLoopback ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}`;
|
|
184
|
+
const ready = [...(opts.readyLines ?? []), `[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`];
|
|
185
|
+
if (!secure && !isLoopback && !opts.host)
|
|
186
|
+
ready.push('[gateway] (TLS off — --http)');
|
|
189
187
|
// a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
|
|
190
188
|
// (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
|
|
191
|
-
listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail,
|
|
189
|
+
listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, ready });
|
|
192
190
|
}
|
|
193
191
|
// re-serialize an upgrade request's headers for replay against the upstream (exported for the host
|
|
194
192
|
// gateway's per-project WS pipe, which replays the same way).
|
|
@@ -223,6 +221,16 @@ function doLogin(req, res, password, setCookie) {
|
|
|
223
221
|
// and would fight Range requests.
|
|
224
222
|
const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg)/;
|
|
225
223
|
const wantsGzip = (req) => /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''));
|
|
224
|
+
// zlib's larger default table can be counterproductive for minified text: memLevel 5 both compresses it
|
|
225
|
+
// further and lowers each stream's working memory. One policy drives buffered and streamed gzip.
|
|
226
|
+
const GZIP_OPTIONS = { level: 9, memLevel: 5 };
|
|
227
|
+
function appendVary(current, token) {
|
|
228
|
+
const values = (Array.isArray(current) ? current : [current ?? ''])
|
|
229
|
+
.flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean);
|
|
230
|
+
if (!values.some((value) => value === '*' || value.toLowerCase() === token.toLowerCase()))
|
|
231
|
+
values.push(token);
|
|
232
|
+
return values.join(', ');
|
|
233
|
+
}
|
|
226
234
|
// reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
|
|
227
235
|
// stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
|
|
228
236
|
// `path` and `headers` optionally override routing inputs (the host gateway strips its /p/:projectId
|
|
@@ -322,16 +330,19 @@ export function proxyHttp(req, res, upstreamPort, path, headers = req.headers, u
|
|
|
322
330
|
received.once('close', () => { if (!received.complete)
|
|
323
331
|
failFromUpstream(); });
|
|
324
332
|
const type = String(received.headers['content-type'] || '');
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
333
|
+
const eligible = !received.headers['content-encoding'] && COMPRESSIBLE.test(type) && !type.startsWith('text/event-stream');
|
|
334
|
+
const responseHeaders = eligible
|
|
335
|
+
? { ...received.headers, vary: appendVary(received.headers.vary, 'Accept-Encoding') }
|
|
336
|
+
: received.headers;
|
|
337
|
+
if (!eligible || !wantsGzip(req)) {
|
|
338
|
+
res.writeHead(received.statusCode || 502, responseHeaders);
|
|
328
339
|
received.pipe(res);
|
|
329
340
|
return;
|
|
330
341
|
}
|
|
331
|
-
const headers = { ...
|
|
342
|
+
const headers = { ...responseHeaders, 'content-encoding': 'gzip' };
|
|
332
343
|
delete headers['content-length']; // streamed; the encoded length isn't knowable up front
|
|
333
344
|
res.writeHead(received.statusCode || 502, headers);
|
|
334
|
-
transform = createGzip();
|
|
345
|
+
transform = createGzip(GZIP_OPTIONS);
|
|
335
346
|
transform.once('error', failFromUpstream);
|
|
336
347
|
received.pipe(transform).pipe(res);
|
|
337
348
|
});
|
|
@@ -473,17 +484,21 @@ export function serveStatic(req, res, distDir, urlPath) {
|
|
|
473
484
|
const type = MIME[extname(file)] || 'application/octet-stream';
|
|
474
485
|
const cacheControl = /[\\/]assets[\\/]/.test(file) ? 'public, max-age=31536000, immutable' : 'no-cache';
|
|
475
486
|
const raw = readFileSync(file);
|
|
476
|
-
|
|
487
|
+
const compressible = COMPRESSIBLE.test(type);
|
|
488
|
+
const headers = { 'Content-Type': type, 'Cache-Control': cacheControl };
|
|
489
|
+
if (compressible)
|
|
490
|
+
headers.Vary = appendVary(undefined, 'Accept-Encoding');
|
|
491
|
+
if (wantsGzip(req) && compressible) {
|
|
477
492
|
const mtime = statSync(file).mtimeMs;
|
|
478
493
|
let hit = gzMemo.get(file);
|
|
479
494
|
if (!hit || hit.mtime !== mtime) {
|
|
480
|
-
hit = { mtime, gz: gzipSync(raw) };
|
|
495
|
+
hit = { mtime, gz: gzipSync(raw, GZIP_OPTIONS) };
|
|
481
496
|
gzMemo.set(file, hit);
|
|
482
497
|
}
|
|
483
|
-
res.writeHead(200, {
|
|
498
|
+
res.writeHead(200, { ...headers, 'Content-Encoding': 'gzip' });
|
|
484
499
|
return res.end(hit.gz);
|
|
485
500
|
}
|
|
486
|
-
res.writeHead(200,
|
|
501
|
+
res.writeHead(200, headers);
|
|
487
502
|
res.end(raw);
|
|
488
503
|
}
|
|
489
504
|
function sendHtml(res, status, html) {
|
|
@@ -498,6 +513,15 @@ function sendHtml(res, status, html) {
|
|
|
498
513
|
// installed user has no source tree for). See [[packaging]].
|
|
499
514
|
export function serveDashboardLocal(opts) {
|
|
500
515
|
const distDir = resolveDistDir();
|
|
501
|
-
|
|
502
|
-
|
|
516
|
+
startGateway({
|
|
517
|
+
host: opts.host ?? '127.0.0.1',
|
|
518
|
+
publicPort: opts.port,
|
|
519
|
+
upstreamPort: opts.apiPort,
|
|
520
|
+
password: '',
|
|
521
|
+
tls: null,
|
|
522
|
+
distDir,
|
|
523
|
+
label: 'dashboard',
|
|
524
|
+
projectRoot: opts.projectRoot,
|
|
525
|
+
readyLines: [`[dashboard] serving ${distDir}, /api → backend :${opts.apiPort}`],
|
|
526
|
+
});
|
|
503
527
|
}
|
package/dist/graphCache.js
CHANGED
|
@@ -3,7 +3,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
|
3
3
|
import { isAbsolute, join, resolve } from 'node:path';
|
|
4
4
|
import { rebasePublishedSessions } from '@spexcode/spec-core';
|
|
5
5
|
import { buildBoard, spliceSessions } from './graphSnapshot.js';
|
|
6
|
-
import { headSha, repoRoot, withGitAbortSignal } from '@spexcode/spec-core';
|
|
6
|
+
import { headSha, repoRoot, requireGitWorkspace, withGitAbortSignal } from '@spexcode/spec-core';
|
|
7
7
|
import { listSessionIds, mainBranch, mainCheckout, readPublicRecordEntry, sessionArtifactPath, sessionRecordPath } from '@spexcode/spec-core';
|
|
8
8
|
import { boardThreads } from './issues.js';
|
|
9
9
|
import { resolveForgeHost } from '@spexcode/spec-forge/drivers';
|
|
@@ -162,6 +162,7 @@ function sessionInputRevision() {
|
|
|
162
162
|
}
|
|
163
163
|
function boardInputRevision(board) {
|
|
164
164
|
const root = repoRoot();
|
|
165
|
+
requireGitWorkspace(root);
|
|
165
166
|
const session = sessionInputRevision();
|
|
166
167
|
// Durable active records are the current root set; a cached ordinary row may be stale and must not replace
|
|
167
168
|
// them. The one projection-only addition is explicit: listSessions republishes an archived-runtime hazard
|
package/dist/graphSnapshot.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { loadSpecs } from '@spexcode/spec-core';
|
|
1
|
+
import { loadSpecs, requireGitWorkspace } from '@spexcode/spec-core';
|
|
2
2
|
import { resolveLayout } from '@spexcode/spec-core';
|
|
3
3
|
import { listSessions } from './sessions.js';
|
|
4
4
|
import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
|
|
@@ -12,6 +12,7 @@ import { sessionEvalProjections } from '@spexcode/spec-eval/sessioneval';
|
|
|
12
12
|
// The application adapter is the sole reader of runtime/forge state. graph.ts only receives this result.
|
|
13
13
|
export async function boardSnapshot() {
|
|
14
14
|
const root = repoRoot();
|
|
15
|
+
requireGitWorkspace(root);
|
|
15
16
|
const [specs, sessions] = await Promise.all([loadSpecs(), listSessions()]);
|
|
16
17
|
const layout = await resolveLayout({ activeSessionIds: sessions.map((session) => session.id) });
|
|
17
18
|
const nodeIds = [...new Set([
|
package/dist/harness.d.ts
CHANGED
|
@@ -24,6 +24,14 @@ export type FailureSubscription = {
|
|
|
24
24
|
close(): void;
|
|
25
25
|
readonly closed: Promise<string | null>;
|
|
26
26
|
};
|
|
27
|
+
export type DeliveryTransportState = {
|
|
28
|
+
kind: 'reachable';
|
|
29
|
+
} | {
|
|
30
|
+
kind: 'unproven';
|
|
31
|
+
} | {
|
|
32
|
+
kind: 'unreachable';
|
|
33
|
+
reason: string;
|
|
34
|
+
};
|
|
27
35
|
export type ProcTable = Map<number, {
|
|
28
36
|
ppid: number;
|
|
29
37
|
comm: string;
|
|
@@ -137,10 +145,12 @@ export interface Harness {
|
|
|
137
145
|
slashCommands(): SlashCommand[];
|
|
138
146
|
liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline';
|
|
139
147
|
launchReady?(current: () => HarnessLaunchReadyRecord | null, deadline: number): Promise<HarnessLaunchReadinessFence | null>;
|
|
140
|
-
|
|
148
|
+
launchPayloadProof?: true;
|
|
149
|
+
exactNativeTargetId(rec: HarnessLivenessRecord & {
|
|
141
150
|
harnessSessionId?: string | null;
|
|
142
151
|
}): string | null;
|
|
143
152
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>;
|
|
153
|
+
deliveryTransport?(rec: HarnessDeliveryRecord): Promise<DeliveryTransportState>;
|
|
144
154
|
observeTurnFailures?(rec: HarnessDeliveryRecord, onFailure: (failure: TurnFailure) => void): FailureSubscription;
|
|
145
155
|
interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>;
|
|
146
156
|
cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>;
|
|
@@ -185,7 +195,7 @@ export interface Harness {
|
|
|
185
195
|
resumeArg(rec: {
|
|
186
196
|
session: string;
|
|
187
197
|
harnessSessionId?: string | null;
|
|
188
|
-
}): string;
|
|
198
|
+
}, pendingLaunchPayload?: string | null): string;
|
|
189
199
|
}
|
|
190
200
|
export type DispatchResult = {
|
|
191
201
|
ok: boolean;
|
|
@@ -193,6 +203,8 @@ export type DispatchResult = {
|
|
|
193
203
|
};
|
|
194
204
|
export type HarnessDeliveryRecord = {
|
|
195
205
|
session: string;
|
|
206
|
+
stopped?: boolean;
|
|
207
|
+
archived?: boolean;
|
|
196
208
|
worktreePath?: string;
|
|
197
209
|
harnessSessionId?: string | null;
|
|
198
210
|
runtimeDir?: string;
|
|
@@ -205,6 +217,7 @@ export type HarnessArtifacts = {
|
|
|
205
217
|
};
|
|
206
218
|
export declare const legacyRvSock: (id: string) => string;
|
|
207
219
|
export declare const scopedRvSock: (id: string, dir?: string) => string;
|
|
220
|
+
export declare function assertRvSockPath(id: string, dir?: string): string;
|
|
208
221
|
export declare const rvSock: (id: string) => string;
|
|
209
222
|
export declare function stampRvSock(id: string, dir?: string): string;
|
|
210
223
|
export type ListenerProbe = 'live' | 'dead' | 'unproven';
|
package/dist/harness.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
1
|
+
import { closeSync, openSync, readSync, writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { join, dirname, basename } from 'node:path';
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto';
|
|
@@ -9,11 +9,11 @@ import { fileURLToPath } from 'node:url';
|
|
|
9
9
|
import { claudeSlashCommands, codexSlashCommands, opencodeSlashCommands, piSlashCommands } from './slash-commands.js';
|
|
10
10
|
import { OPENCODE_EVENTS, opencodePluginSource } from './opencode.js';
|
|
11
11
|
import { piExtensionSource, writePiTrust, removePiTrust } from './pi-harness.js';
|
|
12
|
-
import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
|
|
12
|
+
import { claudeHeadlessColdRuntime, claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadless, interruptClaudeHeadless } from './claude-headless.js';
|
|
13
13
|
import { codexHeadlessLaunchCommand } from './codex-headless.js';
|
|
14
|
-
import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
|
|
14
|
+
import { opencodeHeadlessColdRuntime, opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js';
|
|
15
15
|
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless, piHeadlessColdRuntime } from './pi-headless.js';
|
|
16
|
-
import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from '@spexcode/spec-core';
|
|
16
|
+
import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath, spexcodeHome } from '@spexcode/spec-core';
|
|
17
17
|
import { git } from '@spexcode/spec-core';
|
|
18
18
|
import { shQuote } from './sh.js';
|
|
19
19
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime } from '@spexcode/spec-core';
|
|
@@ -67,9 +67,9 @@ export async function adapterLoadedReferenceState(records, runtimeDir = runtimeR
|
|
|
67
67
|
// sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
|
|
68
68
|
// that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
|
|
69
69
|
// `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
|
|
70
|
-
// and Enters can't be corrupted the way `tmux send-keys` was. It lives in
|
|
71
|
-
// no
|
|
72
|
-
// deliver writes to it.
|
|
70
|
+
// and Enters can't be corrupted the way `tmux send-keys` was. It lives in SpexCode's own durable store, and
|
|
71
|
+
// its launch-time stamp still gives it no independent lifecycle. liveness CONNECTS to it (a live LISTENER, not
|
|
72
|
+
// merely the file — see rendezvousListening); deliver writes to it.
|
|
73
73
|
//
|
|
74
74
|
// The path is a LAUNCH-TIME FACT, recorded — not a formula every consumer re-derives. The id alone was not
|
|
75
75
|
// enough to name it: `SPEXCODE_HOME` scopes the store and `SPEXCODE_TMUX` scopes the tmux server, so two
|
|
@@ -83,18 +83,22 @@ export async function adapterLoadedReferenceState(records, runtimeDir = runtimeR
|
|
|
83
83
|
// `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
|
|
84
84
|
// the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
|
|
85
85
|
export const legacyRvSock = (id) => join(tmpdir(), `spexcode-rv-${id}.sock`);
|
|
86
|
-
// @@@
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
// this
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
86
|
+
// @@@ scoped rendezvous path - a rendezvous endpoint IS this session's address, so it needs a durable home and
|
|
87
|
+
// a bounded name. `<SPEXCODE_HOME>/s/<16hex>/c` is both: the store is SpexCode-owned (normally ~/.spexcode),
|
|
88
|
+
// while one fixed digest of runtime scope + session identity gives every world/session its own short directory
|
|
89
|
+
// without carrying either raw value in the pathname. This is deliberately NOT the Codex app-server rule: that
|
|
90
|
+
// endpoint routes project-shared threads; this one names a session-owned listener. Existing launches retain
|
|
91
|
+
// their stamped old path through rvSock(); only a new stamp uses this derivation.
|
|
92
|
+
const RENDEZVOUS_SUN_PATH_LIMIT = 104;
|
|
93
|
+
export const scopedRvSock = (id, dir = runtimeRoot()) => join(spexcodeHome(), 's', createHash('sha1').update(`${dir}\0${id}`).digest('hex').slice(0, 16), 'c');
|
|
94
|
+
export function assertRvSockPath(id, dir = runtimeRoot()) {
|
|
95
|
+
const path = scopedRvSock(id, dir);
|
|
96
|
+
const bytes = Buffer.byteLength(path);
|
|
97
|
+
if (bytes >= RENDEZVOUS_SUN_PATH_LIMIT) {
|
|
98
|
+
throw new Error(`rendezvous socket path is ${bytes} bytes (must be < ${RENDEZVOUS_SUN_PATH_LIMIT}): ${path}; shorten SPEXCODE_HOME before creating this session`);
|
|
99
|
+
}
|
|
100
|
+
return path;
|
|
101
|
+
}
|
|
98
102
|
const rvStamp = (id) => sessionArtifactPath(id, 'rv.path');
|
|
99
103
|
export const rvSock = (id) => {
|
|
100
104
|
try {
|
|
@@ -107,7 +111,8 @@ export const rvSock = (id) => {
|
|
|
107
111
|
// launch's half: derive this session's socket in ITS runtime and record it, so every later reader (launch env,
|
|
108
112
|
// liveness probe, delivery, teardown) reads the one path the agent actually bound.
|
|
109
113
|
export function stampRvSock(id, dir = runtimeRoot()) {
|
|
110
|
-
const path =
|
|
114
|
+
const path = assertRvSockPath(id, dir);
|
|
115
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
111
116
|
mkdirSync(dirname(rvStamp(id)), { recursive: true });
|
|
112
117
|
writeFileSync(rvStamp(id), path);
|
|
113
118
|
return path;
|
|
@@ -140,6 +145,15 @@ export function listenerAt(path, timeoutMs = 800) {
|
|
|
140
145
|
});
|
|
141
146
|
}
|
|
142
147
|
export const rendezvousListening = (id, timeoutMs = 800) => listenerAt(rvSock(id), timeoutMs);
|
|
148
|
+
const rendezvousDeliveryTransportAt = async (path) => {
|
|
149
|
+
const probe = await listenerAt(path);
|
|
150
|
+
if (probe === 'live')
|
|
151
|
+
return { kind: 'reachable' };
|
|
152
|
+
if (probe === 'unproven')
|
|
153
|
+
return { kind: 'unproven' };
|
|
154
|
+
return { kind: 'unreachable', reason: 'its launch-time rendezvous listener is absent or refusing connections' };
|
|
155
|
+
};
|
|
156
|
+
const rendezvousDeliveryTransport = (rec) => rendezvousDeliveryTransportAt(rvSock(rec.session));
|
|
143
157
|
// The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
|
|
144
158
|
// dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
|
|
145
159
|
// dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
|
|
@@ -277,6 +291,17 @@ function claudeForkTransport(sourceSessionId, runtimeDir) {
|
|
|
277
291
|
}
|
|
278
292
|
return null;
|
|
279
293
|
}
|
|
294
|
+
async function claudeDeliveryTransport(rec) {
|
|
295
|
+
const fork = claudeForkTransport(rec.session, rec.runtimeDir);
|
|
296
|
+
if (!fork)
|
|
297
|
+
return rendezvousDeliveryTransport(rec);
|
|
298
|
+
const forkState = await rendezvousDeliveryTransportAt(fork.sock);
|
|
299
|
+
if (forkState.kind !== 'unreachable' || fork.sock === rvSock(rec.session))
|
|
300
|
+
return forkState;
|
|
301
|
+
// A stale roster row must not strand a source worker whose stamped launch transport is still reachable.
|
|
302
|
+
return rendezvousDeliveryTransport(rec);
|
|
303
|
+
}
|
|
304
|
+
const unprovenDeliveryTransport = async () => ({ kind: 'unproven' });
|
|
280
305
|
function replyViaSocket(sock, text, mid, auth) {
|
|
281
306
|
return new Promise((resolve) => {
|
|
282
307
|
let settled = false;
|
|
@@ -505,8 +530,8 @@ export function codexLaunchCommand(id, codexCmd = 'codex', serverCmd, dir = runt
|
|
|
505
530
|
// TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
|
|
506
531
|
// codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
|
|
507
532
|
// thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
|
|
508
|
-
// does thread/start { cwd = this worktree } on the shared per-project app-server,
|
|
509
|
-
//
|
|
533
|
+
// does thread/start { cwd = this worktree } on the shared per-project app-server, fires the tail as the
|
|
534
|
+
// FIRST turn, materializes the rollout, and stages the new id + payload proof for the lifecycle owner.
|
|
510
535
|
// Either way it ends with a thread id, which the visible TUI then RESUMES (the rollout persists on disk),
|
|
511
536
|
// rendering it natively. A new launch's tail is always ONE single-quoted prompt arg, so it can never be the
|
|
512
537
|
// literal "--resume" marker — the discriminator is unambiguous. codex-launch only prints an id once its
|
|
@@ -1290,8 +1315,15 @@ async function codexColdPreflightOnce(threadId, dir = runtimeRoot(), expectedGen
|
|
|
1290
1315
|
const presence = codexPresenceFromStatus(statusById.get(id));
|
|
1291
1316
|
if (presence === 'active')
|
|
1292
1317
|
return { ok: false, reason: `Codex subtree member ${id} has an active turn` };
|
|
1293
|
-
if (presence === 'unknown')
|
|
1294
|
-
|
|
1318
|
+
if (presence === 'unknown') {
|
|
1319
|
+
const rollout = codexRolloutTurnSettlement(id);
|
|
1320
|
+
if (!rollout.settled) {
|
|
1321
|
+
return {
|
|
1322
|
+
ok: false,
|
|
1323
|
+
reason: `Codex subtree member ${id} turn state is unknown: live Codex client did not report a determinate turn state; ${rollout.reason}`,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1295
1327
|
if (archivedSet.has(id))
|
|
1296
1328
|
return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` };
|
|
1297
1329
|
}
|
|
@@ -1329,17 +1361,49 @@ async function codexColdPreflight(threadId, dir = runtimeRoot(), expectedGenerat
|
|
|
1329
1361
|
}
|
|
1330
1362
|
throw new Error('unreachable Codex cold preflight retry state');
|
|
1331
1363
|
}
|
|
1364
|
+
// A corrupt record has no binding we can trust. Locate its one materialized native thread before cold proof;
|
|
1365
|
+
// choosing current or legacy would redirect a destructive operation across a generation boundary.
|
|
1366
|
+
async function codexEndpointForOrphanThread(threadId, dir = runtimeRoot()) {
|
|
1367
|
+
const tracked = codexGenerationEndpoints(dir);
|
|
1368
|
+
const endpoints = tracked.length ? tracked : [legacyCodexGenerationEndpoint(dir)];
|
|
1369
|
+
const scans = await Promise.all(endpoints.map(async (endpoint) => {
|
|
1370
|
+
const generation = codexMutationGeneration(dir, endpoint);
|
|
1371
|
+
if (!generation)
|
|
1372
|
+
return { endpoint, reason: 'Codex shared app-server generation is unproven' };
|
|
1373
|
+
const [active, archived] = await Promise.all([
|
|
1374
|
+
codexThreadList(endpoint.socketPath, { archived: false, sourceKinds: [] }),
|
|
1375
|
+
codexThreadList(endpoint.socketPath, { archived: true, sourceKinds: [] }),
|
|
1376
|
+
]);
|
|
1377
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1378
|
+
return { endpoint, reason: 'Codex shared app-server generation changed during orphan location' };
|
|
1379
|
+
if (!active.ok)
|
|
1380
|
+
return { endpoint, reason: active.error };
|
|
1381
|
+
if (!archived.ok)
|
|
1382
|
+
return { endpoint, reason: archived.error };
|
|
1383
|
+
return { endpoint, generation, containsTarget: active.ids.includes(threadId) || archived.ids.includes(threadId) };
|
|
1384
|
+
}));
|
|
1385
|
+
const failed = scans.find((scan) => 'reason' in scan);
|
|
1386
|
+
if (failed)
|
|
1387
|
+
return { ok: false, reason: `${failed.reason} while locating orphan Codex thread ${threadId} on generation ${failed.endpoint.id}` };
|
|
1388
|
+
const matches = scans.filter((scan) => 'containsTarget' in scan && scan.containsTarget);
|
|
1389
|
+
if (matches.length !== 1) {
|
|
1390
|
+
const detail = matches.length ? matches.map((scan) => scan.endpoint.id).join(', ') : 'none';
|
|
1391
|
+
return { ok: false, reason: `Codex orphan thread ${threadId} has ${matches.length === 0 ? 'no materialized' : 'ambiguous'} generation location (${detail})` };
|
|
1392
|
+
}
|
|
1393
|
+
return { ok: true, endpoint: matches[0].endpoint, generation: matches[0].generation };
|
|
1394
|
+
}
|
|
1332
1395
|
async function codexQuarantineOrphanThread(threadId, opts) {
|
|
1333
1396
|
const dir = runtimeRoot();
|
|
1334
|
-
const generation = codexMutationGeneration(dir);
|
|
1335
|
-
if (!generation)
|
|
1336
|
-
return { ok: false, reason: 'Codex shared app-server generation is unproven' };
|
|
1337
1397
|
const owners = governedSharedRuntimeOwners(dir, 'codex-app-server', threadId, opts.excludingSessionId);
|
|
1338
1398
|
if (owners === null)
|
|
1339
1399
|
return { ok: false, reason: 'governed Codex thread-owner census is unreadable' };
|
|
1340
1400
|
if (owners.length)
|
|
1341
1401
|
return { ok: false, reason: `Codex native thread ${threadId} has governed owner(s) ${owners.join(', ')}` };
|
|
1342
|
-
const
|
|
1402
|
+
const location = await codexEndpointForOrphanThread(threadId, dir);
|
|
1403
|
+
if (!location.ok)
|
|
1404
|
+
return location;
|
|
1405
|
+
const { endpoint, generation } = location;
|
|
1406
|
+
const before = await codexColdPreflight(threadId, dir, generation, endpoint);
|
|
1343
1407
|
if (!before.ok)
|
|
1344
1408
|
return before;
|
|
1345
1409
|
const plan = before.receipt;
|
|
@@ -1362,7 +1426,6 @@ async function codexQuarantineOrphanThread(threadId, opts) {
|
|
|
1362
1426
|
if (plan.activeIds.length !== 1 || plan.activeIds[0] !== threadId || plan.archivedIds.length)
|
|
1363
1427
|
return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` };
|
|
1364
1428
|
const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId);
|
|
1365
|
-
const legacy = legacyCodexGenerationEndpoint(dir);
|
|
1366
1429
|
// Quarantine archives one exact orphan, so it pays the same flush a subtree member does when that orphan is
|
|
1367
1430
|
// loaded; the budget is derived the same way rather than being a second, differently-wrong constant.
|
|
1368
1431
|
let orphanBudgetMs = CODEX_MUTATION_BASE_MS;
|
|
@@ -1372,10 +1435,10 @@ async function codexQuarantineOrphanThread(threadId, opts) {
|
|
|
1372
1435
|
return { ok: false, reason: `Codex native thread ${threadId} is loaded and its rollout exists but cannot be measured, so the archive flush budget is unknown` };
|
|
1373
1436
|
orphanBudgetMs = codexArchiveBudgetMs(rollout.bytes);
|
|
1374
1437
|
}
|
|
1375
|
-
const archived = await codexThreadMutation(
|
|
1438
|
+
const archived = await codexThreadMutation(endpoint.socketPath, 'thread/archive', threadId, { dir, endpoint, generation }, undefined, orphanBudgetMs);
|
|
1376
1439
|
if (!archived.ok)
|
|
1377
1440
|
return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}${archived.commit === 'unknown' ? '; commit state is unknown' : ''}` };
|
|
1378
|
-
const after = await codexColdPreflight(threadId, dir, generation);
|
|
1441
|
+
const after = await codexColdPreflight(threadId, dir, generation, endpoint);
|
|
1379
1442
|
const failed = (reason) => ({ ok: false, reason });
|
|
1380
1443
|
if (!after.ok) {
|
|
1381
1444
|
const restored = await rollback();
|
|
@@ -1917,6 +1980,45 @@ export function codexRolloutBytes(threadId, root) {
|
|
|
1917
1980
|
}
|
|
1918
1981
|
return { bytes: 0 };
|
|
1919
1982
|
}
|
|
1983
|
+
const CODEX_ROLLOUT_TAIL_BYTES = 64 * 1024;
|
|
1984
|
+
const CODEX_ROLLOUT_TERMINAL_EVENTS = new Set(['task_complete', 'task_completed', 'turn_complete', 'turn_completed']);
|
|
1985
|
+
function codexRolloutTurnSettlement(threadId, root) {
|
|
1986
|
+
const path = codexRolloutPath(threadId, root);
|
|
1987
|
+
if (!path)
|
|
1988
|
+
return { settled: false, reason: 'rollout is missing' };
|
|
1989
|
+
let fd = null;
|
|
1990
|
+
try {
|
|
1991
|
+
const size = statSync(path).size;
|
|
1992
|
+
if (size === 0)
|
|
1993
|
+
return { settled: false, reason: 'rollout has no terminal record' };
|
|
1994
|
+
const length = Math.min(size, CODEX_ROLLOUT_TAIL_BYTES);
|
|
1995
|
+
const tail = Buffer.allocUnsafe(length);
|
|
1996
|
+
fd = openSync(path, 'r');
|
|
1997
|
+
if (readSync(fd, tail, 0, length, size - length) !== length)
|
|
1998
|
+
return { settled: false, reason: 'rollout tail is unreadable' };
|
|
1999
|
+
const line = tail.toString('utf8').split('\n').reverse().find((value) => value.trim());
|
|
2000
|
+
if (!line)
|
|
2001
|
+
return { settled: false, reason: 'rollout has no terminal record' };
|
|
2002
|
+
let event;
|
|
2003
|
+
try {
|
|
2004
|
+
event = JSON.parse(line);
|
|
2005
|
+
}
|
|
2006
|
+
catch {
|
|
2007
|
+
return { settled: false, reason: 'rollout tail is incomplete or malformed' };
|
|
2008
|
+
}
|
|
2009
|
+
const payload = event && typeof event === 'object' ? event.payload : null;
|
|
2010
|
+
const terminal = event && typeof event === 'object' && event.type === 'event_msg' &&
|
|
2011
|
+
payload && typeof payload === 'object' && CODEX_ROLLOUT_TERMINAL_EVENTS.has(String(payload.type || ''));
|
|
2012
|
+
return terminal ? { settled: true } : { settled: false, reason: 'rollout has no terminal record' };
|
|
2013
|
+
}
|
|
2014
|
+
catch {
|
|
2015
|
+
return { settled: false, reason: 'rollout tail is unreadable' };
|
|
2016
|
+
}
|
|
2017
|
+
finally {
|
|
2018
|
+
if (fd !== null)
|
|
2019
|
+
closeSync(fd);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
1920
2022
|
// poll until the thread's rollout lands (resume-ready) or the budget runs out. Returns false on timeout so the
|
|
1921
2023
|
// caller can FAIL LOUD instead of handing `resume` / the stored record a non-resumable id. The budget must
|
|
1922
2024
|
// exceed launch.sh's fast-fail threshold so a genuine failure exits PAST it — the retry loop then treats it as a
|
|
@@ -2262,6 +2364,7 @@ const socketListenerLiveness = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive)
|
|
|
2262
2364
|
const socketListenerOrPidAliveLiveness = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) => (tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline');
|
|
2263
2365
|
const panePidLiveness = (_rec, tmuxAlive, _runtimeDir, pane) => (tmuxAlive && pane?.pidAlive === true ? 'online' : 'offline');
|
|
2264
2366
|
const recordOnline = (rec) => rec.stopped ? 'offline' : 'online';
|
|
2367
|
+
const sessionHomeLiveness = (_rec, tmuxAlive) => tmuxAlive ? 'online' : 'offline';
|
|
2265
2368
|
// @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
|
|
2266
2369
|
// socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
|
|
2267
2370
|
// faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
|
|
@@ -2333,7 +2436,8 @@ export const claudeHarness = {
|
|
|
2333
2436
|
// the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
|
|
2334
2437
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
2335
2438
|
liveness: socketListenerLiveness,
|
|
2336
|
-
|
|
2439
|
+
exactNativeTargetId: (rec) => rec.session,
|
|
2440
|
+
deliveryTransport: claudeDeliveryTransport,
|
|
2337
2441
|
deliver: (rec, text) => deliverViaClaudeRendezvous(rec.session, text, rec.mid, rec.runtimeDir),
|
|
2338
2442
|
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2339
2443
|
coldRuntime: async () => ({ ok: true }),
|
|
@@ -2359,18 +2463,20 @@ export const claudeHeadlessHarness = {
|
|
|
2359
2463
|
id: 'claude-headless',
|
|
2360
2464
|
sessionEnvVar: harnessIdentity('claude-headless').sessionEnvVar,
|
|
2361
2465
|
headless: true,
|
|
2362
|
-
runtimeOwnership: '
|
|
2466
|
+
runtimeOwnership: 'leaf',
|
|
2363
2467
|
ownsRendezvous: false,
|
|
2364
2468
|
paneTitleIsSelfSummary: false,
|
|
2365
2469
|
launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
|
|
2366
2470
|
launchEnv: noLaunchEnv,
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
liveness: recordOnline,
|
|
2471
|
+
liveness: sessionHomeLiveness,
|
|
2472
|
+
deliveryTransport: unprovenDeliveryTransport,
|
|
2370
2473
|
deliver: deliverViaClaudeHeadless,
|
|
2371
2474
|
interrupt: interruptClaudeHeadless,
|
|
2372
2475
|
cleanupRuntime: (rec) => unlinkSocks(claudeHeadlessSock(rec.session)),
|
|
2373
|
-
coldRuntime: async () =>
|
|
2476
|
+
coldRuntime: async (rec) => {
|
|
2477
|
+
const result = await claudeHeadlessColdRuntime(rec);
|
|
2478
|
+
return result.ok ? { ok: true } : { ok: false, reason: result.error || 'claude-headless runtime remains unproven' };
|
|
2479
|
+
},
|
|
2374
2480
|
deliveryBlockedBy: undefined,
|
|
2375
2481
|
};
|
|
2376
2482
|
function codexRuntimeDescriptor(endpoint, runtimeDir) {
|
|
@@ -2405,8 +2511,16 @@ function codexRuntimeDescriptors(runtimeDir) {
|
|
|
2405
2511
|
return (endpoints.length ? endpoints : [legacyCodexGenerationEndpoint(runtimeDir)])
|
|
2406
2512
|
.map((endpoint) => codexRuntimeDescriptor(endpoint, runtimeDir));
|
|
2407
2513
|
}
|
|
2514
|
+
function codexResumeArg(rec, pendingLaunchPayload) {
|
|
2515
|
+
if (rec.harnessSessionId)
|
|
2516
|
+
return `--resume ${rec.harnessSessionId}`;
|
|
2517
|
+
if (pendingLaunchPayload == null)
|
|
2518
|
+
throw new Error(`session ${rec.session}: native identity is absent and the authoritative resolved launch payload is missing; refusing to create an empty thread`);
|
|
2519
|
+
return shQuote(pendingLaunchPayload);
|
|
2520
|
+
}
|
|
2408
2521
|
export const codexHarness = {
|
|
2409
2522
|
id: 'codex',
|
|
2523
|
+
launchPayloadProof: true,
|
|
2410
2524
|
dispatchId: 'codex',
|
|
2411
2525
|
headless: false,
|
|
2412
2526
|
sharedRuntimeSpawn: true,
|
|
@@ -2477,7 +2591,7 @@ export const codexHarness = {
|
|
|
2477
2591
|
return pane.pidAlive ? 'online' : 'offline';
|
|
2478
2592
|
return paneTreeRunsCodex(pane) ? 'online' : 'offline';
|
|
2479
2593
|
},
|
|
2480
|
-
|
|
2594
|
+
exactNativeTargetId: (rec) => rec.harnessSessionId || null,
|
|
2481
2595
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
2482
2596
|
observeTurnFailures: codexTurnFailureObserver,
|
|
2483
2597
|
interrupt: interruptCodexTurn,
|
|
@@ -2641,8 +2755,8 @@ export const codexHarness = {
|
|
|
2641
2755
|
sharedRuntimes: codexRuntimeDescriptors,
|
|
2642
2756
|
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
2643
2757
|
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
2644
|
-
// the owned id, the SAME conversation);
|
|
2645
|
-
resumeArg:
|
|
2758
|
+
// the owned id, the SAME conversation); no identity → replay the authoritative resolved launch payload.
|
|
2759
|
+
resumeArg: codexResumeArg,
|
|
2646
2760
|
// codex's own settled failure: a thread id whose rollout is not on disk can never be resumed, so the launch
|
|
2647
2761
|
// that says so has already decided. (Its transient sibling — the rollout still being written — is handled
|
|
2648
2762
|
// BEFORE launch by waitForCodexRollout, so what reaches here is the permanent case.)
|
|
@@ -2754,6 +2868,7 @@ async function codexHeadlessReadinessProof(current) {
|
|
|
2754
2868
|
export const codexHeadlessHarness = {
|
|
2755
2869
|
...codexHarness,
|
|
2756
2870
|
id: 'codex-headless',
|
|
2871
|
+
launchPayloadProof: true,
|
|
2757
2872
|
sessionEnvVar: harnessIdentity('codex-headless').sessionEnvVar,
|
|
2758
2873
|
headless: true,
|
|
2759
2874
|
runtimeOwnership: 'adapter',
|
|
@@ -2779,9 +2894,9 @@ export const codexHeadlessHarness = {
|
|
|
2779
2894
|
await new Promise((resolve) => setTimeout(resolve, Math.min(200, remaining)));
|
|
2780
2895
|
}
|
|
2781
2896
|
},
|
|
2782
|
-
// There is no TUI to restart and the project app-server keeps
|
|
2783
|
-
//
|
|
2784
|
-
resumeArg:
|
|
2897
|
+
// There is no TUI to restart and the project app-server keeps an identified thread addressable. A pre-identity
|
|
2898
|
+
// recovery still replays the authoritative resolved launch payload through codex-launch.
|
|
2899
|
+
resumeArg: codexResumeArg,
|
|
2785
2900
|
};
|
|
2786
2901
|
// @@@ piHarness - the pi adapter (@earendil-works/pi-coding-agent). pi is the CLOSEST to claude of the four:
|
|
2787
2902
|
// the caller pins the session id at launch (`--session-id <id>`, creating the session if missing), the shim
|
|
@@ -2824,7 +2939,8 @@ export const piHarness = {
|
|
|
2824
2939
|
// claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
|
|
2825
2940
|
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
2826
2941
|
liveness: socketListenerLiveness,
|
|
2827
|
-
|
|
2942
|
+
exactNativeTargetId: (rec) => rec.session,
|
|
2943
|
+
deliveryTransport: rendezvousDeliveryTransport,
|
|
2828
2944
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
|
|
2829
2945
|
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2830
2946
|
coldRuntime: async () => ({ ok: true }),
|
|
@@ -2835,18 +2951,19 @@ export const piHarness = {
|
|
|
2835
2951
|
// pi-headless is an independent harness: its materialization surface is literally pi's, while a resident
|
|
2836
2952
|
// controller owns non-interactive text-mode turns. Active turns steer through pi's rendezvous extension;
|
|
2837
2953
|
// idle delivery cold-wakes the exact saved session with `--session` (never `--session-id`, which would create a
|
|
2838
|
-
// new conversation). The
|
|
2954
|
+
// new conversation). The exact tmux home is its public addressability and physical-cold boundary.
|
|
2839
2955
|
export const piHeadlessHarness = {
|
|
2840
2956
|
...piHarness,
|
|
2841
2957
|
id: 'pi-headless',
|
|
2842
2958
|
sessionEnvVar: harnessIdentity('pi-headless').sessionEnvVar,
|
|
2843
2959
|
headless: true,
|
|
2960
|
+
deliveryTransport: unprovenDeliveryTransport,
|
|
2844
2961
|
// The controller is a per-session process launched in the target tmux pane. Its launch-registered PID
|
|
2845
|
-
// and argv session id are exact leaf ownership evidence
|
|
2962
|
+
// and argv session id are exact leaf ownership evidence.
|
|
2846
2963
|
runtimeOwnership: 'leaf',
|
|
2847
2964
|
paneTitleIsSelfSummary: false,
|
|
2848
2965
|
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
2849
|
-
liveness:
|
|
2966
|
+
liveness: sessionHomeLiveness,
|
|
2850
2967
|
deliver: deliverViaPiHeadless,
|
|
2851
2968
|
cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
|
|
2852
2969
|
coldRuntime: async (rec) => {
|
|
@@ -2888,7 +3005,7 @@ export const zcodeHarness = {
|
|
|
2888
3005
|
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject); },
|
|
2889
3006
|
slashCommands: () => [],
|
|
2890
3007
|
liveness: panePidLiveness,
|
|
2891
|
-
|
|
3008
|
+
exactNativeTargetId: () => null,
|
|
2892
3009
|
deliver: async () => { throw new Error(ZCODE_CONTROL_UNAVAILABLE); },
|
|
2893
3010
|
cleanupRuntime: async () => { },
|
|
2894
3011
|
coldRuntime: async () => ({ ok: true }),
|
|
@@ -2934,7 +3051,8 @@ export const opencodeHarness = {
|
|
|
2934
3051
|
// (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
|
|
2935
3052
|
// load still reads honestly from the process signal instead of a false offline.
|
|
2936
3053
|
liveness: socketListenerOrPidAliveLiveness,
|
|
2937
|
-
|
|
3054
|
+
exactNativeTargetId: (rec) => rec.harnessSessionId || null,
|
|
3055
|
+
deliveryTransport: rendezvousDeliveryTransport,
|
|
2938
3056
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
|
|
2939
3057
|
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2940
3058
|
coldRuntime: async () => ({ ok: true }),
|
|
@@ -2951,12 +3069,14 @@ export const opencodeHeadlessHarness = {
|
|
|
2951
3069
|
id: 'opencode-headless',
|
|
2952
3070
|
sessionEnvVar: harnessIdentity('opencode-headless').sessionEnvVar,
|
|
2953
3071
|
headless: true,
|
|
2954
|
-
runtimeOwnership: '
|
|
3072
|
+
runtimeOwnership: 'leaf',
|
|
3073
|
+
deliveryTransport: unprovenDeliveryTransport,
|
|
2955
3074
|
launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3075
|
+
liveness: sessionHomeLiveness,
|
|
3076
|
+
coldRuntime: async (rec) => {
|
|
3077
|
+
const result = await opencodeHeadlessColdRuntime(rec);
|
|
3078
|
+
return result.ok ? { ok: true } : { ok: false, reason: result.error || 'opencode-headless runtime remains unproven' };
|
|
3079
|
+
},
|
|
2960
3080
|
deliver: async (rec, text) => {
|
|
2961
3081
|
return deliverViaSocketOrWake(rec.session, text, rec.mid, () => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)), `opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`);
|
|
2962
3082
|
},
|