spexcode 0.5.8 → 0.5.9
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/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
package/spec-cli/src/harness.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './la
|
|
|
17
17
|
import { git } from './git.js'
|
|
18
18
|
import { shQuote } from './sh.js'
|
|
19
19
|
import { detachedRuntimeGenerationToken, migrateLegacyDetachedRuntimeReceipt, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
20
|
+
import { codexGenerationEndpoints, codexGenerationSocketPath, currentCodexGeneration, legacyCodexGenerationEndpoint, readCodexGenerationLedger, resolveCodexGenerationForSession, type CodexGenerationEndpoint } from './codex-runtime-generations.js'
|
|
20
21
|
|
|
21
22
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
22
23
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -56,6 +57,9 @@ export type SharedRuntimeDescriptor = {
|
|
|
56
57
|
label: string
|
|
57
58
|
pidFile: string
|
|
58
59
|
receiptFile: string
|
|
60
|
+
// When a resident adapter has several immutable generations, this resolves the one exact descriptor a
|
|
61
|
+
// governed target owns. Product code consumes the selection without learning adapter-specific identities.
|
|
62
|
+
targetDescriptorKey?: (rec: HarnessLivenessRecord & { harnessSessionId?: string | null }) => string | null
|
|
59
63
|
// Lightweight project-wide resident census used by read projections. It must return exact loaded IDs without
|
|
60
64
|
// per-thread reads; the full probe remains the resource/lifecycle surface that also reads turn state.
|
|
61
65
|
residency?: () => Promise<{ healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean }>
|
|
@@ -110,7 +114,9 @@ export async function adapterLoadedReferenceState(
|
|
|
110
114
|
const recordKeys = new Map<string, string[]>()
|
|
111
115
|
for (const rec of records) {
|
|
112
116
|
if (!rec.harnessSessionId) continue
|
|
113
|
-
const
|
|
117
|
+
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
118
|
+
const exactKey = harness.targetDescriptorKey?.(rec) ?? null
|
|
119
|
+
const keys = (harness.sharedRuntimes?.(runtimeDir) ?? []).filter((descriptor) => !exactKey || descriptor.key === exactKey).map((descriptor) => {
|
|
114
120
|
descriptors.set(descriptor.key, descriptor)
|
|
115
121
|
return descriptor.key
|
|
116
122
|
})
|
|
@@ -272,14 +278,11 @@ export interface Harness {
|
|
|
272
278
|
// token it registered for this record (session id, harness thread/generation, or null when unprovable);
|
|
273
279
|
// product lifecycle code never branches on harness names to invent this identity.
|
|
274
280
|
leafOwnerNeedle?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
// before it proves a concurrent connect kicked the chunk (the daemon is single-connection) → resend; a wall
|
|
278
|
-
// expiry on a still-open connection is a busy-not-lost agent → optimistic ok (see replyViaSocket). codex:
|
|
279
|
-
// JSON-RPC on the same app-server WebSocket the
|
|
281
|
+
// Poke a live session and report whether this immediate channel accepted the attempt. Claude-family adapters
|
|
282
|
+
// write one idempotent rendezvous reply; Codex uses JSON-RPC on the same app-server WebSocket the
|
|
280
283
|
// visible TUI uses — it reads the thread live and either `turn/steer`s the message INTO an in-progress turn
|
|
281
284
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
282
|
-
//
|
|
285
|
+
// `ok=false` leaves the durable timeline line for the turn-boundary reader.
|
|
283
286
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
284
287
|
// Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
|
|
285
288
|
// transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
|
|
@@ -311,10 +314,12 @@ export interface Harness {
|
|
|
311
314
|
// Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
|
|
312
315
|
// references and protect a sibling-owned control plane without learning harness command names.
|
|
313
316
|
sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
|
|
314
|
-
// the
|
|
315
|
-
//
|
|
316
|
-
|
|
317
|
-
//
|
|
317
|
+
// Select the exact shared descriptor a record owns when an adapter has more than one resident generation.
|
|
318
|
+
// Null is an unproven binding and must fail closed before lifecycle mutation.
|
|
319
|
+
targetDescriptorKey?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
|
|
320
|
+
// The one pane state where this harness swallows an immediate poke: given live pane text, return the reason
|
|
321
|
+
// to skip that courtesy attempt or null when the pane can take it. sendText consults this after append;
|
|
322
|
+
// absent on harnesses whose poke ignores pane state. Claude's
|
|
318
323
|
// TUI's sessions panel ("← for agents") enqueues an injected reply to the panel context and never drains it
|
|
319
324
|
// — verified live: parsed + enqueued, no dequeue, no turn, daemon silent.
|
|
320
325
|
deliveryBlockedBy?(paneText: string): string | null
|
|
@@ -338,26 +343,20 @@ export interface Harness {
|
|
|
338
343
|
resumeArg(rec: { session: string; harnessSessionId?: string | null }): string
|
|
339
344
|
}
|
|
340
345
|
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
// the DAEMON-PARSE layer (the atomic reply+repaint chunk answered `repaint-done`, or the wall expired on a
|
|
347
|
-
// still-open connection — busy, not lost; see replyViaSocket); codex at the application layer (the app-server
|
|
348
|
-
// accepted `turn/steer`/`turn/start`). `error` carries a human-readable reason that propagates to the API route
|
|
349
|
-
// (non-2xx) and the CLI/dashboard. Defined here because it is the harness DELIVERY contract; sessions.ts
|
|
350
|
-
// re-exports it for its existing importers.
|
|
351
|
-
export type DispatchResult = { ok: boolean; outcome?: DispatchOutcome; error?: string }
|
|
346
|
+
// `ok` describes only this round's immediate adapter poke: the socket write, native controller request, or
|
|
347
|
+
// app-server turn request reached its channel. [[dispatch]] has already decided delivery at the timeline append,
|
|
348
|
+
// so an error means only that the reader will show the line at a later turn boundary. Defined here because this
|
|
349
|
+
// is the adapter result sessions.ts consumes for same-turn timing.
|
|
350
|
+
export type DispatchResult = { ok: boolean; error?: string }
|
|
352
351
|
export type HarnessDeliveryRecord = {
|
|
353
352
|
session: string
|
|
354
353
|
worktreePath?: string
|
|
355
354
|
harnessSessionId?: string | null
|
|
356
355
|
runtimeDir?: string
|
|
357
356
|
launchCmd?: string | null
|
|
358
|
-
//
|
|
359
|
-
// ignore it; product routing never needs to know which harness recognizes the marker.
|
|
360
|
-
|
|
357
|
+
// The message's timeline id ([[session-timeline]]). Codex maps it to its native `clientUserMessageId`, while
|
|
358
|
+
// other adapters may ignore it; product routing never needs to know which harness recognizes the marker.
|
|
359
|
+
mid?: string
|
|
361
360
|
}
|
|
362
361
|
// the on-demand surface artifacts a materialize pass wrote, by node NAME — so clean() knows EXACTLY which
|
|
363
362
|
// skill subdirs / agent files are SpexCode's to remove (name-scoped, never a blind wipe of a dir the user may
|
|
@@ -384,8 +383,20 @@ export type HarnessArtifacts = { skills: readonly string[]; agents: readonly str
|
|
|
384
383
|
// `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
|
|
385
384
|
// the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
|
|
386
385
|
export const legacyRvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
|
|
386
|
+
|
|
387
|
+
// @@@ rendezvousSocketBase - macOS's TMPDIR commonly expands to /var/folders/<long-user-path>, leaving a
|
|
388
|
+
// UUID-scoped socket beyond its 104-byte sun_path limit. It can be created and observed by pathname but every
|
|
389
|
+
// connect fails EINVAL, which looks exactly like an unresponsive worker. Use a literal short /tmp spelling
|
|
390
|
+
// (not tmpdir(), whose resolved form is long on macOS) with the same private per-uid directory pattern as the
|
|
391
|
+
// Codex runtime socket. Existing launches retain their stamped old path through rvSock(); only new launches use
|
|
392
|
+
// this base.
|
|
393
|
+
const rendezvousSocketBase = () => {
|
|
394
|
+
const base = join('/tmp', `spexcode-rv-${process.getuid?.() ?? 0}`)
|
|
395
|
+
mkdirSync(base, { recursive: true, mode: 0o700 })
|
|
396
|
+
return base
|
|
397
|
+
}
|
|
387
398
|
export const scopedRvSock = (id: string, dir = runtimeRoot()) =>
|
|
388
|
-
join(
|
|
399
|
+
join(rendezvousSocketBase(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`)
|
|
389
400
|
const rvStamp = (id: string) => sessionArtifactPath(id, 'rv.path')
|
|
390
401
|
export const rvSock = (id: string): string => {
|
|
391
402
|
try { return readFileSync(rvStamp(id), 'utf8').trim() || legacyRvSock(id) } catch { return legacyRvSock(id) }
|
|
@@ -451,9 +462,7 @@ export const rendezvousListening = (id: string, timeoutMs = 800): Promise<Listen
|
|
|
451
462
|
// (idempotent mkdir) so every consumer — launch bake, liveness connect, delivery, tests — shares one creation
|
|
452
463
|
// point. The `.pid`/`.log`/`.lock` files carry no sun_path limit and stay in `runtimeRoot`.
|
|
453
464
|
export const codexAppServerSock = (dir = runtimeRoot()) => {
|
|
454
|
-
|
|
455
|
-
mkdirSync(base, { recursive: true, mode: 0o700 })
|
|
456
|
-
return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
|
|
465
|
+
return codexGenerationSocketPath(dir)
|
|
457
466
|
}
|
|
458
467
|
export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
|
|
459
468
|
export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
|
|
@@ -462,11 +471,11 @@ type CodexRuntimeGenerationProof = Readonly<{
|
|
|
462
471
|
identity: VerifiedDetachedRuntime
|
|
463
472
|
socket: Readonly<{ path: string; dev: number; ino: number }>
|
|
464
473
|
}>
|
|
465
|
-
function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
|
|
474
|
+
function codexRuntimeGenerationProof(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): CodexRuntimeGenerationProof | null {
|
|
466
475
|
try {
|
|
467
|
-
const pid = Number(readFileSync(
|
|
468
|
-
const detached = verifyDetachedRuntime(pid,
|
|
469
|
-
const socketPath =
|
|
476
|
+
const pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim())
|
|
477
|
+
const detached = verifyDetachedRuntime(pid, endpoint.receiptFile)
|
|
478
|
+
const socketPath = endpoint.socketPath
|
|
470
479
|
const socket = statSync(socketPath)
|
|
471
480
|
if (!(pid > 0) || !detached.ok || !socket.isSocket()) return null
|
|
472
481
|
return Object.freeze({
|
|
@@ -477,21 +486,31 @@ function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGeneratio
|
|
|
477
486
|
}
|
|
478
487
|
const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
|
|
479
488
|
`${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
|
|
480
|
-
function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
|
|
481
|
-
const proof = codexRuntimeGenerationProof(dir)
|
|
489
|
+
function codexRuntimeGeneration(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): string | null {
|
|
490
|
+
const proof = codexRuntimeGenerationProof(dir, endpoint)
|
|
482
491
|
return proof ? codexRuntimeGenerationToken(proof) : null
|
|
483
492
|
}
|
|
484
493
|
|
|
485
|
-
function codexMutationGeneration(dir = runtimeRoot()): string | null {
|
|
486
|
-
const current = codexRuntimeGeneration(dir)
|
|
494
|
+
function codexMutationGeneration(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): string | null {
|
|
495
|
+
const current = codexRuntimeGeneration(dir, endpoint)
|
|
487
496
|
if (current) return current
|
|
497
|
+
if (endpoint.id !== 'legacy') return null
|
|
488
498
|
let pid: number
|
|
489
499
|
try {
|
|
490
500
|
pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
|
|
491
501
|
if (!Number.isInteger(pid) || pid <= 0 || !statSync(codexAppServerSock(dir)).isSocket()) return null
|
|
492
502
|
} catch { return null }
|
|
493
503
|
if (!migrateLegacyDetachedRuntimeReceipt(pid, codexAppServerLegacyScope(dir), codexAppServerReceipt(dir))) return null
|
|
494
|
-
return codexRuntimeGeneration(dir)
|
|
504
|
+
return codexRuntimeGeneration(dir, endpoint)
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const codexDescriptorKey = (endpoint: CodexGenerationEndpoint) => endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`
|
|
508
|
+
|
|
509
|
+
function codexEndpointForRecord(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, dir = runtimeRoot()): CodexGenerationEndpoint | null {
|
|
510
|
+
if (!rec.harnessSessionId) return null
|
|
511
|
+
const ledger = readCodexGenerationLedger(dir)
|
|
512
|
+
if (ledger.revision === 0 && !ledger.current && !Object.keys(ledger.generations).length) return legacyCodexGenerationEndpoint(dir)
|
|
513
|
+
return resolveCodexGenerationForSession(dir, rec.session, rec.harnessSessionId)
|
|
495
514
|
}
|
|
496
515
|
|
|
497
516
|
// the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
|
|
@@ -501,94 +520,59 @@ function codexMutationGeneration(dir = runtimeRoot()): string | null {
|
|
|
501
520
|
const PKG = fileURLToPath(new URL('..', import.meta.url))
|
|
502
521
|
const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
503
522
|
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
// the previous optimistic write (return ok once the reply line flushed) LOST prompts whenever a probe landed in
|
|
508
|
-
// the write→parse window, a window that widens exactly when claude is busy mid-turn (field: dashboard messages
|
|
509
|
-
// recorded `sent` with no trace in the claude transcript; measured 2/10 lost under a 20ms probe hammer). The
|
|
510
|
-
// daemon parses a chunk's complete lines in ONE synchronous loop, so writing `{type:reply}` + `{type:repaint}`
|
|
511
|
-
// as ONE chunk makes the pair indivisible — a kick loses BOTH or NEITHER — and the outcome decidable from this
|
|
512
|
-
// connection alone:
|
|
513
|
-
// `repaint-done` arrives → the reply line before it was parsed (in-order barrier) → ok, CONFIRMED.
|
|
514
|
-
// 'close' before it → the chunk was never parsed (kicked by a concurrent connect) → resolve kicked:true
|
|
515
|
-
// so deliverViaRendezvous RESENDS — a proven loss, so the retry cannot duplicate.
|
|
516
|
-
// wall expires, conn open → a busy event loop is DELAYING, not losing (the 2500ms-wall lesson: ack absence is
|
|
517
|
-
// NOT non-delivery) → ok, OPTIMISTIC — never a false failure on a busy worker.
|
|
518
|
-
// `reply-rejected`/`auth-rejected`/`shutting-down` → loud failure, not retried.
|
|
519
|
-
// Other daemon lines (heartbeat, state patches) are ignored. Never throws.
|
|
520
|
-
type ReplyOutcome = DispatchResult & { kicked?: boolean }
|
|
521
|
-
function replyViaSocket(sock: string, text: string, wallMs = 10_000): Promise<ReplyOutcome> {
|
|
523
|
+
// The timeline is the message's copy, so rendezvous needs no receipt protocol. It writes one idempotent poke
|
|
524
|
+
// carrying the timeline mid and reports only whether that write reached the local transport.
|
|
525
|
+
function replyViaSocket(sock: string, text: string, mid?: string): Promise<DispatchResult> {
|
|
522
526
|
return new Promise((resolve) => {
|
|
523
527
|
let settled = false
|
|
524
528
|
let c: ReturnType<typeof createConnection>
|
|
525
|
-
const done = (r:
|
|
529
|
+
const done = (r: DispatchResult) => {
|
|
526
530
|
if (settled) return
|
|
527
531
|
settled = true
|
|
528
|
-
|
|
529
|
-
try { c?.destroy() } catch { /* */ }
|
|
532
|
+
if (!r.ok) try { c?.destroy() } catch { /* */ }
|
|
530
533
|
resolve(r)
|
|
531
534
|
}
|
|
532
|
-
const wall = setTimeout(() => done({ ok: true }), wallMs)
|
|
533
535
|
try {
|
|
534
536
|
c = createConnection({ path: sock })
|
|
535
537
|
} catch (e) {
|
|
536
538
|
done({ ok: false, error: `rendezvous socket connect threw: ${String(e)}` })
|
|
537
539
|
return
|
|
538
540
|
}
|
|
539
|
-
// ECONNRESET/EPIPE are the KICK surfacing as an error: the daemon destroy()s the previous connection the
|
|
540
|
-
// moment a new one connects, and destroying a socket with OUR chunk still unread raises RST — whereas a
|
|
541
|
-
// parsed chunk answers repaint-done (readable even after a later close) before any clean FIN. So both codes
|
|
542
|
-
// PROVE the chunk was never parsed → retryable, same as the clean pre-parse close. ECONNREFUSED/ENOENT
|
|
543
|
-
// (daemon gone) stay loud.
|
|
544
541
|
c.on('error', (e: NodeJS.ErrnoException) => {
|
|
545
542
|
const code = e?.code || String(e)
|
|
546
|
-
|
|
547
|
-
done({ ok: false, ...(kicked ? { kicked } : {}), error: `rendezvous socket error: ${code} — prompt NOT delivered` })
|
|
548
|
-
})
|
|
549
|
-
c.on('close', () => done({ ok: false, kicked: true, error: 'rendezvous connection was closed before the daemon parsed the prompt (kicked by a concurrent connect)' }))
|
|
550
|
-
c.on('connect', () => c.write(JSON.stringify({ type: 'reply', text }) + '\n' + JSON.stringify({ type: 'repaint' }) + '\n'))
|
|
551
|
-
let buf = ''
|
|
552
|
-
c.on('data', (d) => {
|
|
553
|
-
buf += d.toString('utf8')
|
|
554
|
-
let nl
|
|
555
|
-
while ((nl = buf.indexOf('\n')) >= 0) {
|
|
556
|
-
const line = buf.slice(0, nl)
|
|
557
|
-
buf = buf.slice(nl + 1)
|
|
558
|
-
let type = ''
|
|
559
|
-
try { type = (JSON.parse(line) as { type?: string })?.type ?? '' } catch { continue }
|
|
560
|
-
if (type === 'repaint-done') return done({ ok: true })
|
|
561
|
-
if (type === 'reply-rejected' || type === 'auth-rejected') return done({ ok: false, error: `rendezvous daemon rejected the prompt (${type}) — prompt NOT delivered` })
|
|
562
|
-
if (type === 'shutting-down') return done({ ok: false, error: 'agent is shutting down — prompt NOT delivered' })
|
|
563
|
-
}
|
|
543
|
+
done({ ok: false, error: `rendezvous socket error: ${code}` })
|
|
564
544
|
})
|
|
545
|
+
c.on('close', () => done({ ok: false, error: 'rendezvous connection closed before the poke was written' }))
|
|
546
|
+
c.on('connect', () => c.write(
|
|
547
|
+
JSON.stringify({ type: 'reply', text, ...(mid ? { mid } : {}) }) + '\n',
|
|
548
|
+
(error) => {
|
|
549
|
+
if (error) return done({ ok: false, error: `rendezvous socket write failed: ${error.message}` })
|
|
550
|
+
c.end()
|
|
551
|
+
done({ ok: true })
|
|
552
|
+
},
|
|
553
|
+
))
|
|
565
554
|
})
|
|
566
555
|
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
// alive). Then the atomic parse-confirmed write; a KICKED outcome is a proven whole-chunk loss, so it resends
|
|
570
|
-
// (bounded attempts + jitter so re-collision with the probe cadence is unlikely); exhausted retries fail loud.
|
|
571
|
-
const DELIVER_ATTEMPTS = 3
|
|
572
|
-
export async function deliverViaRendezvous(id: string, text: string, wallMs?: number): Promise<DispatchResult> {
|
|
556
|
+
const POKE_ATTEMPTS = 2
|
|
557
|
+
export async function deliverViaRendezvous(id: string, text: string, mid?: string): Promise<DispatchResult> {
|
|
573
558
|
const sock = rvSock(id)
|
|
574
|
-
|
|
575
|
-
let
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (last.ok || !last.kicked) return { ok: last.ok, ...(last.error ? { error: last.error } : {}) }
|
|
579
|
-
await new Promise((r) => setTimeout(r, 60 + Math.random() * 140))
|
|
559
|
+
let last: DispatchResult = { ok: false, error: 'not attempted' }
|
|
560
|
+
for (let attempt = 0; attempt < POKE_ATTEMPTS; attempt++) {
|
|
561
|
+
last = await replyViaSocket(sock, text, mid)
|
|
562
|
+
if (last.ok) return last
|
|
580
563
|
}
|
|
581
|
-
return { ok: false, error: `rendezvous
|
|
564
|
+
return { ok: false, error: `rendezvous poke failed after ${POKE_ATTEMPTS} attempts: ${last.error ?? 'unknown error'}` }
|
|
582
565
|
}
|
|
583
566
|
|
|
584
567
|
export async function deliverViaSocketOrWake(
|
|
585
568
|
id: string,
|
|
586
569
|
text: string,
|
|
570
|
+
mid: string | undefined,
|
|
587
571
|
coldWake: () => Promise<DispatchResult>,
|
|
588
572
|
unprovenError: string,
|
|
589
573
|
): Promise<DispatchResult> {
|
|
590
574
|
const probe = await rendezvousListening(id)
|
|
591
|
-
if (probe === 'live') return deliverViaRendezvous(id, text)
|
|
575
|
+
if (probe === 'live') return deliverViaRendezvous(id, text, mid)
|
|
592
576
|
if (probe === 'unproven') return { ok: false, error: unprovenError }
|
|
593
577
|
return coldWake()
|
|
594
578
|
}
|
|
@@ -708,18 +692,8 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
|
|
|
708
692
|
// fresh app-server (where codex-launch never runs) still trusts our hooks. Hence the flag lives on the resume
|
|
709
693
|
// TUI, never on the app-server invocation. Guarded against a double-flag when an env override already carries it.
|
|
710
694
|
const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
|
|
711
|
-
const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
|
|
712
|
-
const pid = codexAppServerPid(dir)
|
|
713
|
-
const receipt = codexAppServerReceipt(dir)
|
|
714
|
-
const log = join(dir, 'codex-app-server.log')
|
|
715
|
-
const lock = join(dir, 'codex-app-server.lock')
|
|
716
695
|
const script = [
|
|
717
696
|
`dir=${shQuote(dir)}`,
|
|
718
|
-
`sock=${shQuote(sock)}`,
|
|
719
|
-
`pid=${shQuote(pid)}`,
|
|
720
|
-
`receipt=${shQuote(receipt)}`,
|
|
721
|
-
`log=${shQuote(log)}`,
|
|
722
|
-
`lock=${shQuote(lock)}`,
|
|
723
697
|
// codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
|
|
724
698
|
// WE already hold the launcher's real cmd here (it drives the app-server + resume TUI + tuiBypass above), so
|
|
725
699
|
// pin it into the environment the codex-launch child inherits. Without this the child falls back to a bare
|
|
@@ -727,51 +701,14 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
|
|
|
727
701
|
// probes the WRONG binary — deciding "no --dangerously-bypass-hook-trust support" and silently dropping the
|
|
728
702
|
// thread/start bypass, so the worktree's hooks stay untrusted and NO lifecycle hooks fire.
|
|
729
703
|
`export SPEXCODE_CODEX_CMD=${shQuote(codexCmd)}`,
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
//
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
// bootstrap, leaving the pane at the shell). Spin on `mkdir "$lock.d"` with a bounded wait; after ~10s
|
|
738
|
-
// (200 * 0.05s, safely above the ~5s socket-wait a legit holder needs) treat the dir as orphaned by a dead
|
|
739
|
-
// launcher and clear it, so a stale lock can never deadlock a launch. Held ONLY across the check-and-start,
|
|
740
|
-
// released immediately after. Unlike flock (held until every fd on its open file description closes) a mkdir
|
|
741
|
-
// lock has no inherited-fd hazard, so the long-lived daemon can't pin it — no fd-9 gymnastics needed.
|
|
742
|
-
'lockd="$lock.d"',
|
|
743
|
-
'_lk=0',
|
|
744
|
-
'until mkdir "$lockd" 2>/dev/null; do',
|
|
745
|
-
' _lk=$((_lk+1)); [ "$_lk" -ge 200 ] && { rm -rf "$lockd" 2>/dev/null; _lk=0; }',
|
|
746
|
-
' sleep 0.05',
|
|
747
|
-
'done',
|
|
748
|
-
'if [ -S "$sock" ] && [ -s "$pid" ] && ! kill -0 "$(cat "$pid")" 2>/dev/null; then rm -f "$sock"; fi',
|
|
749
|
-
'if [ ! -S "$sock" ]; then',
|
|
750
|
-
// The app-server is a per-PROJECT daemon SHARED across every worktree's threads, so it must run in a STABLE
|
|
751
|
-
// cwd — the runtime dir "$dir", NOT the launch.sh's transient worktree. A daemon started inside a worktree
|
|
752
|
-
// keeps that worktree as its process cwd for its whole life; when the session closes and the worktree is
|
|
753
|
-
// removed, the daemon's cwd becomes a DELETED dir, and codex then fails EVERY new thread's config load with
|
|
754
|
-
// `failed to load configuration: No such file or directory` — bricking codex launch for the whole project
|
|
755
|
-
// until the daemon is killed. Running it from "$dir" (which never gets deleted) makes it deletion-proof.
|
|
756
|
-
// For the SAME reason it must carry no session IDENTITY: it is started by whichever session happened to
|
|
757
|
-
// launch first and then serves every later thread, whose tool shells inherit its env — so a baked
|
|
758
|
-
// SPEXCODE_SESSION_ID (or any adapter's `sessionEnvVar`) is a stale lie for every session but one, and
|
|
759
|
-
// still a lie after that session closes and its record is swept (measured: daemons here running for days
|
|
760
|
-
// under a long-gone session's id). Everything downstream that resolves identity from the env then
|
|
761
|
-
// mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
|
|
762
|
-
// inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
|
|
763
|
-
// The adapter launches its shared control plane in a new OS process group + session. `nohup` alone was not
|
|
764
|
-
// a boundary: the Codex Node launcher reset signal handling and died with the tmux pane. The internal helper
|
|
765
|
-
// uses child_process detached=true, then the process adapter publishes a private receipt only after proving
|
|
766
|
-
// exact PID/start + PGID (and Linux SID). Every consumer re-verifies that same receipt.
|
|
767
|
-
` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$receipt" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
|
|
768
|
-
' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
|
|
704
|
+
// The runtime command is the single generation-ledger boundary. A new turn receives canonical `current`;
|
|
705
|
+
// resume resolves its existing session/thread binding and therefore cannot jump an old conversation to a
|
|
706
|
+
// replacement root. It prints only shell assignments for the exact proven endpoint.
|
|
707
|
+
'if [ "$1" = "--resume" ]; then',
|
|
708
|
+
` eval "$( ${SPEX} internal codex-generation-session "$dir" "$SPEXCODE_SESSION_ID" "$2" )" || exit 1`,
|
|
709
|
+
'else',
|
|
710
|
+
` eval "$( ${SPEX} internal codex-generation-current "$dir" ${shQuote(server)} )" || exit 1`,
|
|
769
711
|
'fi',
|
|
770
|
-
'rmdir "$lockd" 2>/dev/null',
|
|
771
|
-
// The delegated bearer arrived only through fd 9. The shared-spawn helper consumed it (or the already-live
|
|
772
|
-
// socket made it unnecessary); close and scrub the channel before the per-session Codex client starts.
|
|
773
|
-
'[ "${SPEXCODE_MAINTENANCE_DELEGATE_FD:-}" != "9" ] || exec 9<&-',
|
|
774
|
-
'unset SPEXCODE_MAINTENANCE_DELEGATE_FD SPEXCODE_MAINTENANCE_SESSION_ID',
|
|
775
712
|
// TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
|
|
776
713
|
// codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
|
|
777
714
|
// thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
|
|
@@ -867,7 +804,15 @@ export function codexTurnFailureObserver(
|
|
|
867
804
|
): FailureSubscription {
|
|
868
805
|
const threadId = rec.harnessSessionId
|
|
869
806
|
if (!threadId) return { close: () => {}, closed: Promise.resolve(null) }
|
|
870
|
-
const
|
|
807
|
+
const runtimeDir = rec.runtimeDir || runtimeRoot()
|
|
808
|
+
const endpoint = codexEndpointForRecord(rec, runtimeDir)
|
|
809
|
+
if (!endpoint) {
|
|
810
|
+
return {
|
|
811
|
+
close: () => {},
|
|
812
|
+
closed: Promise.resolve(`Codex turn observer refused: no exact generation binding for session ${rec.session}`),
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
const sock = endpoint.socketPath
|
|
871
816
|
const conn: Socket = createConnection(sock)
|
|
872
817
|
const frames: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
873
818
|
let upgraded = false, settled = false
|
|
@@ -959,9 +904,9 @@ export function codexTurnFailureObserver(
|
|
|
959
904
|
|
|
960
905
|
// Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
961
906
|
// defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
|
|
962
|
-
type CodexGenerationFence = { dir: string; generation: string }
|
|
907
|
+
type CodexGenerationFence = { dir: string; endpoint: CodexGenerationEndpoint; generation: string }
|
|
963
908
|
function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive', threadId: string, fence?: CodexGenerationFence): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
964
|
-
const generationError = () => fence && codexRuntimeGeneration(fence.dir) !== fence.generation
|
|
909
|
+
const generationError = () => fence && codexRuntimeGeneration(fence.dir, fence.endpoint) !== fence.generation
|
|
965
910
|
? `Codex ${method} refused because the shared app-server generation changed`
|
|
966
911
|
: null
|
|
967
912
|
const before = generationError()
|
|
@@ -978,7 +923,7 @@ function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/un
|
|
|
978
923
|
resolve(r)
|
|
979
924
|
}
|
|
980
925
|
// thread/archive may wait up to 10s in shutdown_and_wait before the server commits; keep a margin so a
|
|
981
|
-
// legitimate late response is not turned into an early
|
|
926
|
+
// legitimate late response is not turned into an early timeout race.
|
|
982
927
|
const timer = setTimeout(() => done({ ok: false, error: generationError() || `Codex ${method} timed out after 15s` }), 15000)
|
|
983
928
|
conn.on('error', (e) => done({ ok: false, error: generationError() || `Codex ${method} connection failed: ${rpcError(e)}` }))
|
|
984
929
|
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${method}` }) })
|
|
@@ -1093,6 +1038,8 @@ export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true;
|
|
|
1093
1038
|
return result.ok ? { ok: true, referenceIds: result.ids } : result
|
|
1094
1039
|
}
|
|
1095
1040
|
|
|
1041
|
+
const CODEX_TARGET_TURN_CENSUS_MS = 15_000
|
|
1042
|
+
|
|
1096
1043
|
function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }> {
|
|
1097
1044
|
return new Promise((resolve) => {
|
|
1098
1045
|
const conn: Socket = createConnection(sock)
|
|
@@ -1102,23 +1049,25 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
|
|
|
1102
1049
|
if (settled) return
|
|
1103
1050
|
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
1104
1051
|
}
|
|
1105
|
-
const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId}
|
|
1106
|
-
conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId}
|
|
1107
|
-
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId}
|
|
1052
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} turn census timed out after ${CODEX_TARGET_TURN_CENSUS_MS}ms` }), CODEX_TARGET_TURN_CENSUS_MS)
|
|
1053
|
+
conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${rpcError(error)}` }))
|
|
1054
|
+
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` }) })
|
|
1108
1055
|
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
1109
1056
|
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
1110
1057
|
const handle = (json: string) => {
|
|
1111
1058
|
let message: JsonRpc
|
|
1112
1059
|
try { message = JSON.parse(json) } catch { return }
|
|
1113
|
-
if (message.error) return done({ ok: false, error: `Codex target thread ${threadId}
|
|
1060
|
+
if (message.error) return done({ ok: false, error: `Codex target thread ${threadId} turn census failed: ${message.error.message || JSON.stringify(message.error)}` })
|
|
1114
1061
|
if (message.id === 1 && message.result) {
|
|
1115
1062
|
send({ method: 'initialized', params: {} })
|
|
1116
|
-
|
|
1063
|
+
// The guard needs only the current turn. `thread/read {includeTurns:true}` materializes the
|
|
1064
|
+
// entire persisted history, so an old but otherwise healthy thread can time out before close.
|
|
1065
|
+
return send({ id: 2, method: 'thread/turns/list', params: { threadId, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } })
|
|
1117
1066
|
}
|
|
1118
1067
|
if (message.id !== 2 || !message.result) return
|
|
1119
|
-
const turns = (message.result as {
|
|
1068
|
+
const turns = (message.result as { data?: unknown }).data
|
|
1120
1069
|
if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
|
|
1121
|
-
return done({ ok: true, turnPresence:
|
|
1070
|
+
return done({ ok: true, turnPresence: turns.some((turn) => turn && typeof turn === 'object' && (turn as { status?: unknown }).status === 'inProgress') ? 'active' : 'idle' })
|
|
1122
1071
|
}
|
|
1123
1072
|
conn.on('data', (chunk: Buffer) => {
|
|
1124
1073
|
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
@@ -1126,10 +1075,10 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
|
|
|
1126
1075
|
const i = fs.buf.indexOf('\r\n\r\n')
|
|
1127
1076
|
if (i < 0) return
|
|
1128
1077
|
const head = fs.buf.slice(0, i).toString('utf8')
|
|
1129
|
-
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused target thread ${threadId}
|
|
1078
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused target thread ${threadId} turn census: ${head.split('\r\n')[0]}` })
|
|
1130
1079
|
upgraded = true; fs.buf = fs.buf.slice(i + 4); send(wsInitialize)
|
|
1131
1080
|
}
|
|
1132
|
-
if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId}
|
|
1081
|
+
if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} turn census` })
|
|
1133
1082
|
})
|
|
1134
1083
|
})
|
|
1135
1084
|
}
|
|
@@ -1168,10 +1117,10 @@ function codexThreadCollection(sock: string, params: Record<string, unknown>): P
|
|
|
1168
1117
|
})
|
|
1169
1118
|
}
|
|
1170
1119
|
|
|
1171
|
-
async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
|
|
1172
|
-
const generationBefore = codexMutationGeneration(dir)
|
|
1120
|
+
async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeMutationGuard> {
|
|
1121
|
+
const generationBefore = codexMutationGeneration(dir, endpoint)
|
|
1173
1122
|
if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
|
|
1174
|
-
const sock =
|
|
1123
|
+
const sock = endpoint.socketPath
|
|
1175
1124
|
const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
|
|
1176
1125
|
codexLoadedReferenceIds(sock),
|
|
1177
1126
|
codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
@@ -1190,7 +1139,7 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()):
|
|
|
1190
1139
|
if (!target.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: target.error }
|
|
1191
1140
|
targetTurnPresence = target.turnPresence
|
|
1192
1141
|
}
|
|
1193
|
-
if (codexRuntimeGeneration(dir) !== generationBefore)
|
|
1142
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
|
|
1194
1143
|
return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
|
|
1195
1144
|
return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
|
|
1196
1145
|
}
|
|
@@ -1201,6 +1150,7 @@ type CodexColdPlan = Readonly<{
|
|
|
1201
1150
|
kind: 'codex-cold-subtree-v1'
|
|
1202
1151
|
threadId: string
|
|
1203
1152
|
generation: string
|
|
1153
|
+
endpoint: CodexGenerationEndpoint
|
|
1204
1154
|
guard: SharedRuntimeMutationGuard
|
|
1205
1155
|
descendantIds: readonly string[]
|
|
1206
1156
|
parentEdges: readonly (readonly [string, string])[]
|
|
@@ -1220,16 +1170,22 @@ const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
|
|
|
1220
1170
|
if (!value || typeof value !== 'object') return false
|
|
1221
1171
|
const plan = value as Partial<CodexColdPlan>
|
|
1222
1172
|
return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
|
|
1223
|
-
typeof plan.generation === 'string' && Array.isArray(plan.descendantIds) &&
|
|
1173
|
+
typeof plan.generation === 'string' && isEndpointLike(plan.endpoint) && Array.isArray(plan.descendantIds) &&
|
|
1224
1174
|
Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
|
|
1225
1175
|
Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard
|
|
1226
1176
|
}
|
|
1227
1177
|
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1178
|
+
function isEndpointLike(value: unknown): value is CodexGenerationEndpoint {
|
|
1179
|
+
return !!value && typeof value === 'object' && typeof (value as CodexGenerationEndpoint).id === 'string' &&
|
|
1180
|
+
typeof (value as CodexGenerationEndpoint).pidFile === 'string' && typeof (value as CodexGenerationEndpoint).receiptFile === 'string' &&
|
|
1181
|
+
typeof (value as CodexGenerationEndpoint).socketPath === 'string'
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string, endpoint = legacyCodexGenerationEndpoint(dir)): Promise<CodexColdPreflight> {
|
|
1185
|
+
const generation = expectedGeneration ?? codexMutationGeneration(dir, endpoint)
|
|
1186
|
+
if (!generation || codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1231
1187
|
return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
|
|
1232
|
-
const sock =
|
|
1188
|
+
const sock = endpoint.socketPath
|
|
1233
1189
|
const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
|
|
1234
1190
|
codexLoadedReferenceIds(sock),
|
|
1235
1191
|
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
@@ -1237,7 +1193,7 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1237
1193
|
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1238
1194
|
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1239
1195
|
])
|
|
1240
|
-
if (codexRuntimeGeneration(dir) !== generation)
|
|
1196
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1241
1197
|
return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
|
|
1242
1198
|
if (!loaded.ok) return { ok: false, reason: loaded.error }
|
|
1243
1199
|
if (!activeDescendants.ok) return { ok: false, reason: activeDescendants.error }
|
|
@@ -1290,7 +1246,7 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1290
1246
|
const loadedSet = new Set(loaded.referenceIds)
|
|
1291
1247
|
const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
|
|
1292
1248
|
const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
|
|
1293
|
-
if (codexRuntimeGeneration(dir) !== generation)
|
|
1249
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generation)
|
|
1294
1250
|
return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
|
|
1295
1251
|
for (const { id, state } of turnStates) {
|
|
1296
1252
|
if (!state.ok) return { ok: false, reason: state.error }
|
|
@@ -1316,6 +1272,7 @@ async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expecte
|
|
|
1316
1272
|
kind: 'codex-cold-subtree-v1',
|
|
1317
1273
|
threadId,
|
|
1318
1274
|
generation,
|
|
1275
|
+
endpoint,
|
|
1319
1276
|
guard,
|
|
1320
1277
|
descendantIds: Object.freeze([...descendantIds]),
|
|
1321
1278
|
parentEdges: Object.freeze(parentEdges),
|
|
@@ -1354,7 +1311,8 @@ async function codexQuarantineOrphanThread(threadId: string, opts: { excludingSe
|
|
|
1354
1311
|
if (plan.activeIds.length !== 1 || plan.activeIds[0] !== threadId || plan.archivedIds.length)
|
|
1355
1312
|
return { ok: false, reason: `Codex native thread ${threadId} is not one exact active orphan` }
|
|
1356
1313
|
const siblingIds = plan.guard.referenceIds.filter((id) => id !== threadId)
|
|
1357
|
-
const
|
|
1314
|
+
const legacy = legacyCodexGenerationEndpoint(dir)
|
|
1315
|
+
const archived = await codexThreadMutation(legacy.socketPath, 'thread/archive', threadId, { dir, endpoint: legacy, generation })
|
|
1358
1316
|
if (!archived.ok) return { ok: false, reason: `${archived.error} while archiving orphan Codex thread ${threadId}` }
|
|
1359
1317
|
const after = await codexColdPreflight(threadId, dir, generation)
|
|
1360
1318
|
const failed = (reason: string): HarnessOrphanThreadQuarantine => ({ ok: false, reason })
|
|
@@ -1379,13 +1337,15 @@ async function codexMutationGuard(
|
|
|
1379
1337
|
threadId: string,
|
|
1380
1338
|
dir = runtimeRoot(),
|
|
1381
1339
|
opts: { coldReceipt?: unknown } = {},
|
|
1340
|
+
endpoint = legacyCodexGenerationEndpoint(dir),
|
|
1382
1341
|
): Promise<SharedRuntimeMutationGuard> {
|
|
1383
|
-
if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir)
|
|
1342
|
+
if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir, endpoint)
|
|
1384
1343
|
if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
|
|
1385
1344
|
return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' }
|
|
1386
|
-
|
|
1345
|
+
if (opts.coldReceipt.endpoint.id !== endpoint.id) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt names a different generation' }
|
|
1346
|
+
const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation, endpoint)
|
|
1387
1347
|
if (!current.ok) {
|
|
1388
|
-
const guard = await codexTargetMutationGuard(threadId, dir)
|
|
1348
|
+
const guard = await codexTargetMutationGuard(threadId, dir, endpoint)
|
|
1389
1349
|
return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason }
|
|
1390
1350
|
}
|
|
1391
1351
|
const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
|
|
@@ -1401,16 +1361,16 @@ async function codexMutationGuard(
|
|
|
1401
1361
|
}
|
|
1402
1362
|
|
|
1403
1363
|
async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
1404
|
-
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
1364
|
+
if (codexRuntimeGeneration(dir, plan.endpoint) !== plan.generation)
|
|
1405
1365
|
return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
|
|
1406
|
-
const sock =
|
|
1366
|
+
const sock = plan.endpoint.socketPath
|
|
1407
1367
|
const [activeBefore, archivedBefore] = await Promise.all([
|
|
1408
1368
|
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1409
1369
|
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1410
1370
|
])
|
|
1411
1371
|
if (!activeBefore.ok || !archivedBefore.ok)
|
|
1412
1372
|
return { ok: false, reason: 'archive state is unknown and could not be reconciled' }
|
|
1413
|
-
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
1373
|
+
if (codexRuntimeGeneration(dir, plan.endpoint) !== plan.generation)
|
|
1414
1374
|
return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
|
|
1415
1375
|
const activeSet = new Set(activeBefore.ids)
|
|
1416
1376
|
const archivedSet = new Set(archivedBefore.ids)
|
|
@@ -1418,7 +1378,7 @@ async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): P
|
|
|
1418
1378
|
return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' }
|
|
1419
1379
|
if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
|
|
1420
1380
|
return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' }
|
|
1421
|
-
const fence = { dir, generation: plan.generation }
|
|
1381
|
+
const fence = { dir, endpoint: plan.endpoint, generation: plan.generation }
|
|
1422
1382
|
const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id))
|
|
1423
1383
|
for (const id of restoreIds) {
|
|
1424
1384
|
const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence)
|
|
@@ -1428,7 +1388,7 @@ async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): P
|
|
|
1428
1388
|
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1429
1389
|
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1430
1390
|
])
|
|
1431
|
-
const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir) === plan.generation &&
|
|
1391
|
+
const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir, plan.endpoint) === plan.generation &&
|
|
1432
1392
|
plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
|
|
1433
1393
|
plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id))
|
|
1434
1394
|
return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' }
|
|
@@ -1485,19 +1445,19 @@ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: strin
|
|
|
1485
1445
|
// Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
|
|
1486
1446
|
// they are never treated as references by themselves. A loaded thread is a control-plane reference and its
|
|
1487
1447
|
// fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
|
|
1488
|
-
export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRuntimeProbe> {
|
|
1489
|
-
const sock =
|
|
1448
|
+
export function codexSharedRuntimeProbe(dir = runtimeRoot(), endpoint = legacyCodexGenerationEndpoint(dir)): Promise<SharedRuntimeProbe> {
|
|
1449
|
+
const sock = endpoint.socketPath
|
|
1490
1450
|
return (async () => {
|
|
1491
1451
|
// File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
|
|
1492
1452
|
// only a live PID and a live listener establish a resident control plane. This keeps a deliberately absent
|
|
1493
1453
|
// root a healthy empty census while leaving live-but-ambiguous roots loud and visible.
|
|
1494
1454
|
let pid = 0
|
|
1495
|
-
try { pid = Number(readFileSync(
|
|
1455
|
+
try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) } catch { /* absent/stale */ }
|
|
1496
1456
|
const pidLive = pid > 0 && !!processStartToken(pid)
|
|
1497
1457
|
const listener = await listenerAt(sock, 800)
|
|
1498
1458
|
if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
|
|
1499
1459
|
if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
|
|
1500
|
-
const generation = codexRuntimeGeneration(dir)
|
|
1460
|
+
const generation = codexRuntimeGeneration(dir, endpoint)
|
|
1501
1461
|
if (!generation) return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' }
|
|
1502
1462
|
return new Promise<SharedRuntimeProbe>((resolve) => {
|
|
1503
1463
|
const conn: Socket = createConnection(sock)
|
|
@@ -1516,7 +1476,7 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRunt
|
|
|
1516
1476
|
settled = true
|
|
1517
1477
|
clearTimeout(timer)
|
|
1518
1478
|
try { conn.destroy() } catch { /* */ }
|
|
1519
|
-
resolve(result.healthy && codexRuntimeGeneration(dir) !== generation
|
|
1479
|
+
resolve(result.healthy && codexRuntimeGeneration(dir, endpoint) !== generation
|
|
1520
1480
|
? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
|
|
1521
1481
|
: result)
|
|
1522
1482
|
}
|
|
@@ -1690,7 +1650,6 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1690
1650
|
let buf = Buffer.alloc(0), upgraded = false, settled = false
|
|
1691
1651
|
let fragOp = 0, fragBuf = Buffer.alloc(0)
|
|
1692
1652
|
let steering = false // the id-4 message we sent was a steer → an expectedTurnId race may retry as start(5)
|
|
1693
|
-
let injected = false
|
|
1694
1653
|
const done = (r: DispatchResult) => {
|
|
1695
1654
|
if (settled) return
|
|
1696
1655
|
settled = true
|
|
@@ -1698,7 +1657,7 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1698
1657
|
try { conn.destroy() } catch { /* */ }
|
|
1699
1658
|
resolve(r)
|
|
1700
1659
|
}
|
|
1701
|
-
const unresolved = (error: string) => done({ ok: false,
|
|
1660
|
+
const unresolved = (error: string) => done({ ok: false, error })
|
|
1702
1661
|
const timer = setTimeout(() => unresolved(`codex app-server did not confirm the turn within ${codexTurnConfirmMs()}ms`), codexTurnConfirmMs())
|
|
1703
1662
|
conn.on('error', (e) => unresolved(`codex app-server connection failed: ${rpcError(e)}`))
|
|
1704
1663
|
conn.on('close', () => unresolved('codex app-server closed the connection before the turn was confirmed'))
|
|
@@ -1715,7 +1674,7 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1715
1674
|
return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId))
|
|
1716
1675
|
if (m.id === 3) // thread not readable yet (a freshly-started thread is "not materialized
|
|
1717
1676
|
return send(codexInjectMessage(threadId, text, cwd, null, 5, clientUserMessageId)) // before its first user message") → just turn/start
|
|
1718
|
-
return done({ ok: false,
|
|
1677
|
+
return done({ ok: false, error: `codex app-server ${m.id ? `request ${m.id}` : 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}` })
|
|
1719
1678
|
}
|
|
1720
1679
|
// JSON-RPC initialization is ordered. Under a quiet server the premature notification happened to win;
|
|
1721
1680
|
// under shared app-server load it was ignored and every later turn waited until the old 5s wall expired.
|
|
@@ -1723,16 +1682,15 @@ function sendCodexAppServerTurn(sock: string, threadId: string, text: string, cw
|
|
|
1723
1682
|
if (m.id === 2 && m.result) { // loaded-thread list → confirm OUR thread is live, then read it
|
|
1724
1683
|
const loaded = (m.result as { data?: unknown })?.data
|
|
1725
1684
|
if (Array.isArray(loaded) && !loaded.includes(threadId))
|
|
1726
|
-
return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) —
|
|
1685
|
+
return done({ ok: false, error: `Codex thread ${threadId} is not loaded in the app-server (loaded: ${loaded.join(', ') || 'none'}) — immediate poke not accepted` })
|
|
1727
1686
|
return send(hs[3]) // thread is live → read it to decide steer-vs-start
|
|
1728
1687
|
}
|
|
1729
1688
|
if (m.id === 3 && m.result) { // thread read → in-progress turn? steer into it; else start a new one
|
|
1730
1689
|
const turnId = activeTurnIdFromThread(m.result)
|
|
1731
1690
|
steering = !!turnId
|
|
1732
|
-
injected = true
|
|
1733
1691
|
return send(codexInjectMessage(threadId, text, cwd, turnId, 4, clientUserMessageId)) // id 4: turn/steer the live turn, or turn/start
|
|
1734
1692
|
}
|
|
1735
|
-
if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true
|
|
1693
|
+
if ((m.id === 4 || m.id === 5) && m.result) return done({ ok: true }) // steer/start accepted → the model has the message
|
|
1736
1694
|
}
|
|
1737
1695
|
const drainFrames = () => {
|
|
1738
1696
|
for (;;) {
|
|
@@ -1811,26 +1769,29 @@ export async function waitForCodexRollout(threadId: string, timeoutMs = 20000):
|
|
|
1811
1769
|
}
|
|
1812
1770
|
}
|
|
1813
1771
|
|
|
1814
|
-
// codex's
|
|
1772
|
+
// codex's immediate poke uses the app-server JSON-RPC channel that also powers rich clients, never TUI typing.
|
|
1815
1773
|
// The visible TUI is launched against the same project app-server Unix socket, so this injects into the same
|
|
1816
1774
|
// thread the pane is showing — steering an in-progress turn or starting one if idle. A missing captured thread
|
|
1817
|
-
// id or socket
|
|
1775
|
+
// id or socket makes this poke fail; there is no tmux send-keys fallback because that reports "typed", not "accepted".
|
|
1818
1776
|
const pexec = promisify(execFile)
|
|
1819
1777
|
const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
1820
1778
|
async function deliverViaCodexAppServer(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult> {
|
|
1821
1779
|
// the socket is PER-PROJECT (the runtime root), shared by every worktree's thread; the owned thread id on
|
|
1822
1780
|
// the record picks out THIS session's thread.
|
|
1823
|
-
const
|
|
1824
|
-
|
|
1781
|
+
const runtimeDir = rec.runtimeDir ?? runtimeRoot()
|
|
1782
|
+
const endpoint = rec.harnessSessionId ? codexEndpointForRecord(rec, runtimeDir) : currentCodexGeneration(runtimeDir)
|
|
1783
|
+
if (!endpoint) return { ok: false, error: `no exact Codex generation binding for session ${rec.session} — immediate poke unavailable` }
|
|
1784
|
+
const sock = endpoint.socketPath
|
|
1785
|
+
if (!existsSync(sock)) return { ok: false, error: `no Codex app-server socket for this project — immediate poke unavailable` }
|
|
1825
1786
|
// use the backend-owned thread id stored at launch; fall back to reading the one loaded thread only if it's
|
|
1826
1787
|
// empty (a pre-existing session from before the id was stored).
|
|
1827
1788
|
let threadId = rec.harnessSessionId
|
|
1828
1789
|
if (!threadId) {
|
|
1829
1790
|
const r = await codexThreadId(sock)
|
|
1830
|
-
if (!r.ok) return { ok: false, error: `${r.error} —
|
|
1791
|
+
if (!r.ok) return { ok: false, error: `${r.error} — immediate poke unavailable` }
|
|
1831
1792
|
threadId = r.threadId
|
|
1832
1793
|
}
|
|
1833
|
-
return sendCodexAppServerTurn(sock, threadId
|
|
1794
|
+
return sendCodexAppServerTurn(sock, threadId!, text, rec.worktreePath, rec.mid)
|
|
1834
1795
|
}
|
|
1835
1796
|
|
|
1836
1797
|
// idempotent replace of the content between sentinels; the user's own content above/below is preserved. The
|
|
@@ -2179,12 +2140,12 @@ export const claudeHarness: Harness = {
|
|
|
2179
2140
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
2180
2141
|
liveness: socketListenerLiveness,
|
|
2181
2142
|
leafOwnerNeedle: (rec) => rec.session,
|
|
2182
|
-
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
2143
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
|
|
2183
2144
|
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2184
2145
|
coldRuntime: async () => ({ ok: true }),
|
|
2185
|
-
//
|
|
2186
|
-
//
|
|
2187
|
-
//
|
|
2146
|
+
// The TUI's sessions panel ("← for agents") swallows an injected reply into PANEL context and never drains it
|
|
2147
|
+
// (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so skip this courtesy
|
|
2148
|
+
// poke and leave the timeline reader to show the message. Matched on the panel's own
|
|
2188
2149
|
// strings — the new-session composer placeholder, or its footer key hints together (either alone could drift
|
|
2189
2150
|
// across claude versions; requiring the footer PAIR keeps a prose false-positive unlikely).
|
|
2190
2151
|
deliveryBlockedBy: (paneText) =>
|
|
@@ -2220,6 +2181,36 @@ export const claudeHeadlessHarness: Harness = {
|
|
|
2220
2181
|
deliveryBlockedBy: undefined,
|
|
2221
2182
|
}
|
|
2222
2183
|
|
|
2184
|
+
function codexRuntimeDescriptor(endpoint: CodexGenerationEndpoint, runtimeDir: string): SharedRuntimeDescriptor {
|
|
2185
|
+
return {
|
|
2186
|
+
key: codexDescriptorKey(endpoint),
|
|
2187
|
+
label: endpoint.id === 'legacy' ? 'Codex app-server' : `Codex app-server ${endpoint.id.slice(0, 18)}`,
|
|
2188
|
+
pidFile: endpoint.pidFile,
|
|
2189
|
+
receiptFile: endpoint.receiptFile,
|
|
2190
|
+
residency: async () => {
|
|
2191
|
+
let pid = 0
|
|
2192
|
+
try { pid = Number(readFileSync(endpoint.pidFile, 'utf8').trim()) } catch { /* stale/missing pid */ }
|
|
2193
|
+
const pidLive = pid > 0 && !!processStartToken(pid)
|
|
2194
|
+
const listener = await listenerAt(endpoint.socketPath, 800)
|
|
2195
|
+
if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
|
|
2196
|
+
if (pidLive && (!codexRuntimeGeneration(runtimeDir, endpoint) || listener !== 'live'))
|
|
2197
|
+
return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
|
|
2198
|
+
if (!pidLive || listener !== 'live')
|
|
2199
|
+
return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
|
|
2200
|
+
const result = await codexLoadedReferenceIds(endpoint.socketPath)
|
|
2201
|
+
return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
|
|
2202
|
+
},
|
|
2203
|
+
mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts, endpoint),
|
|
2204
|
+
probe: () => codexSharedRuntimeProbe(runtimeDir, endpoint),
|
|
2205
|
+
}
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
function codexRuntimeDescriptors(runtimeDir: string): SharedRuntimeDescriptor[] {
|
|
2209
|
+
const endpoints = codexGenerationEndpoints(runtimeDir)
|
|
2210
|
+
return (endpoints.length ? endpoints : [legacyCodexGenerationEndpoint(runtimeDir)])
|
|
2211
|
+
.map((endpoint) => codexRuntimeDescriptor(endpoint, runtimeDir))
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2223
2214
|
export const codexHarness: Harness = {
|
|
2224
2215
|
id: 'codex',
|
|
2225
2216
|
dispatchId: 'codex',
|
|
@@ -2292,14 +2283,20 @@ export const codexHarness: Harness = {
|
|
|
2292
2283
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
2293
2284
|
observeTurnFailures: codexTurnFailureObserver,
|
|
2294
2285
|
cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
2286
|
+
targetDescriptorKey: (rec) => {
|
|
2287
|
+
const endpoint = codexEndpointForRecord(rec)
|
|
2288
|
+
return endpoint ? codexDescriptorKey(endpoint) : null
|
|
2289
|
+
},
|
|
2295
2290
|
coldRetirementPreflight: async (rec) => {
|
|
2296
2291
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2297
2292
|
const threadId = rec.harnessSessionId
|
|
2298
2293
|
const dir = runtimeRoot()
|
|
2299
|
-
const
|
|
2294
|
+
const endpoint = codexEndpointForRecord(rec, dir)
|
|
2295
|
+
if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
|
|
2296
|
+
const generationBefore = codexRuntimeGeneration(dir, endpoint)
|
|
2300
2297
|
if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
2301
|
-
const result = await codexColdPreflight(threadId, dir, generationBefore)
|
|
2302
|
-
if (codexRuntimeGeneration(dir) !== generationBefore)
|
|
2298
|
+
const result = await codexColdPreflight(threadId, dir, generationBefore, endpoint)
|
|
2299
|
+
if (codexRuntimeGeneration(dir, endpoint) !== generationBefore)
|
|
2303
2300
|
return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
|
|
2304
2301
|
if (!result.ok) return result
|
|
2305
2302
|
if (!result.alreadyCold)
|
|
@@ -2308,21 +2305,25 @@ export const codexHarness: Harness = {
|
|
|
2308
2305
|
},
|
|
2309
2306
|
coldPreflight: async (rec) => {
|
|
2310
2307
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2311
|
-
|
|
2308
|
+
const endpoint = codexEndpointForRecord(rec)
|
|
2309
|
+
return endpoint ? codexColdPreflight(rec.harnessSessionId, runtimeRoot(), undefined, endpoint)
|
|
2310
|
+
: { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
|
|
2312
2311
|
},
|
|
2313
2312
|
coldRuntime: async (rec, suppliedReceipt) => {
|
|
2314
2313
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2315
2314
|
const threadId = rec.harnessSessionId
|
|
2316
2315
|
const dir = runtimeRoot()
|
|
2317
|
-
const
|
|
2318
|
-
|
|
2316
|
+
const endpoint = codexEndpointForRecord(rec, dir)
|
|
2317
|
+
if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
|
|
2318
|
+
const sock = endpoint.socketPath
|
|
2319
|
+
const generationBefore = codexRuntimeGeneration(dir, endpoint)
|
|
2319
2320
|
if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
2320
2321
|
if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
|
|
2321
2322
|
return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' }
|
|
2322
2323
|
const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null
|
|
2323
|
-
if (frozenPlan && frozenPlan.generation !== generationBefore)
|
|
2324
|
+
if (frozenPlan && (frozenPlan.generation !== generationBefore || frozenPlan.endpoint.id !== endpoint.id))
|
|
2324
2325
|
return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' }
|
|
2325
|
-
const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore)
|
|
2326
|
+
const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore, endpoint)
|
|
2326
2327
|
if (!preflight.ok) return preflight
|
|
2327
2328
|
const plan = frozenPlan ?? preflight.receipt
|
|
2328
2329
|
if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
|
|
@@ -2330,12 +2331,12 @@ export const codexHarness: Harness = {
|
|
|
2330
2331
|
!sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
|
|
2331
2332
|
!sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
|
|
2332
2333
|
return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' }
|
|
2333
|
-
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
2334
|
+
if (codexRuntimeGeneration(dir, endpoint) !== plan.generation)
|
|
2334
2335
|
return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' }
|
|
2335
2336
|
if (plan.activeIds.length === 0) return { ok: true }
|
|
2336
2337
|
const subtreeSet = new Set(plan.subtreeIds)
|
|
2337
2338
|
const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId))
|
|
2338
|
-
const fence = { dir, generation: plan.generation }
|
|
2339
|
+
const fence = { dir, endpoint, generation: plan.generation }
|
|
2339
2340
|
|
|
2340
2341
|
const compensate = async (reason: string): Promise<{ ok: false; reason: string }> => {
|
|
2341
2342
|
const restored = await codexRestoreColdPlan(plan, dir)
|
|
@@ -2343,9 +2344,9 @@ export const codexHarness: Harness = {
|
|
|
2343
2344
|
}
|
|
2344
2345
|
|
|
2345
2346
|
const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
|
|
2346
|
-
const after = await codexColdPreflight(threadId, dir, plan.generation)
|
|
2347
|
+
const after = await codexColdPreflight(threadId, dir, plan.generation, endpoint)
|
|
2347
2348
|
if (!after.ok) return after
|
|
2348
|
-
if (codexRuntimeGeneration(dir) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
|
|
2349
|
+
if (codexRuntimeGeneration(dir, endpoint) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
|
|
2349
2350
|
if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
|
|
2350
2351
|
return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` }
|
|
2351
2352
|
if (after.receipt.activeIds.length)
|
|
@@ -2378,7 +2379,9 @@ export const codexHarness: Harness = {
|
|
|
2378
2379
|
return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' }
|
|
2379
2380
|
return codexRestoreColdPlan(suppliedReceipt)
|
|
2380
2381
|
}
|
|
2381
|
-
const
|
|
2382
|
+
const endpoint = codexEndpointForRecord(rec)
|
|
2383
|
+
if (!endpoint) return { ok: false, reason: 'no exact Codex generation binding is registered for this target' }
|
|
2384
|
+
const sock = endpoint.socketPath
|
|
2382
2385
|
const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
|
|
2383
2386
|
const [active, archived] = await Promise.all([
|
|
2384
2387
|
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
@@ -2395,28 +2398,7 @@ export const codexHarness: Harness = {
|
|
|
2395
2398
|
if (!restored.ok) return reconcile()
|
|
2396
2399
|
return reconcile()
|
|
2397
2400
|
},
|
|
2398
|
-
sharedRuntimes:
|
|
2399
|
-
key: 'codex-app-server',
|
|
2400
|
-
label: 'Codex app-server',
|
|
2401
|
-
pidFile: codexAppServerPid(runtimeDir),
|
|
2402
|
-
receiptFile: codexAppServerReceipt(runtimeDir),
|
|
2403
|
-
residency: async () => {
|
|
2404
|
-
const sock = codexAppServerSock(runtimeDir)
|
|
2405
|
-
let pid = 0
|
|
2406
|
-
try { pid = Number(readFileSync(codexAppServerPid(runtimeDir), 'utf8').trim()) } catch { /* stale/missing pid */ }
|
|
2407
|
-
const pidLive = pid > 0 && !!processStartToken(pid)
|
|
2408
|
-
const listener = await listenerAt(sock, 800)
|
|
2409
|
-
if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
|
|
2410
|
-
if (pidLive && (!codexRuntimeGeneration(runtimeDir) || listener !== 'live'))
|
|
2411
|
-
return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
|
|
2412
|
-
if (!pidLive || listener !== 'live')
|
|
2413
|
-
return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
|
|
2414
|
-
const result = await codexLoadedReferenceIds(sock)
|
|
2415
|
-
return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
|
|
2416
|
-
},
|
|
2417
|
-
mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts),
|
|
2418
|
-
probe: () => codexSharedRuntimeProbe(runtimeDir),
|
|
2419
|
-
}],
|
|
2401
|
+
sharedRuntimes: codexRuntimeDescriptors,
|
|
2420
2402
|
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
2421
2403
|
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
2422
2404
|
// the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
|
|
@@ -2485,10 +2467,12 @@ const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string,
|
|
|
2485
2467
|
async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyRecord | null): Promise<CodexHeadlessLaunchReadinessProof | null> {
|
|
2486
2468
|
const record = current()
|
|
2487
2469
|
if (!record?.governed || record.stopped || record.archived || !record.harnessSessionId) return null
|
|
2470
|
+
const endpoint = codexEndpointForRecord(record, record.runtimeDir)
|
|
2471
|
+
if (!endpoint) return null
|
|
2488
2472
|
const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
|
|
2489
|
-
.find((candidate) => candidate.key ===
|
|
2473
|
+
.find((candidate) => candidate.key === codexDescriptorKey(endpoint))
|
|
2490
2474
|
if (!descriptor?.residency) return null
|
|
2491
|
-
const generationBefore = codexRuntimeGenerationProof(record.runtimeDir)
|
|
2475
|
+
const generationBefore = codexRuntimeGenerationProof(record.runtimeDir, endpoint)
|
|
2492
2476
|
if (!generationBefore) return null
|
|
2493
2477
|
let resident: Awaited<ReturnType<NonNullable<SharedRuntimeDescriptor['residency']>>>
|
|
2494
2478
|
try { resident = await descriptor.residency() }
|
|
@@ -2497,7 +2481,7 @@ async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyReco
|
|
|
2497
2481
|
if (!resident.referenceIds.includes(record.harnessSessionId)) return null
|
|
2498
2482
|
const owners = governedSharedRuntimeOwners(record.runtimeDir, descriptor.key, record.harnessSessionId)
|
|
2499
2483
|
if (!owners || owners.length !== 1 || owners[0] !== record.session) return null
|
|
2500
|
-
const generationAfter = codexRuntimeGenerationProof(record.runtimeDir)
|
|
2484
|
+
const generationAfter = codexRuntimeGenerationProof(record.runtimeDir, endpoint)
|
|
2501
2485
|
if (!generationAfter || codexRuntimeGenerationToken(generationBefore) !== codexRuntimeGenerationToken(generationAfter)) return null
|
|
2502
2486
|
return Object.freeze({
|
|
2503
2487
|
kind: 'codex-headless-shared-runtime',
|
|
@@ -2555,7 +2539,7 @@ export const codexHeadlessHarness: Harness = {
|
|
|
2555
2539
|
// extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
|
|
2556
2540
|
// dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
|
|
2557
2541
|
// CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
|
|
2558
|
-
// deliverViaRendezvous and the socket-listener liveness work
|
|
2542
|
+
// deliverViaRendezvous and the socket-listener liveness work through the same adapter seam. Trust: pi gates project-local
|
|
2559
2543
|
// extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
|
|
2560
2544
|
// checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
|
|
2561
2545
|
// one-run defence. See pi-harness.ts for the extension source + trust mechanics.
|
|
@@ -2589,7 +2573,7 @@ export const piHarness: Harness = {
|
|
|
2589
2573
|
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
2590
2574
|
liveness: socketListenerLiveness,
|
|
2591
2575
|
leafOwnerNeedle: (rec) => rec.session,
|
|
2592
|
-
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
2576
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
|
|
2593
2577
|
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2594
2578
|
coldRuntime: async () => ({ ok: true }),
|
|
2595
2579
|
// reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
|
|
@@ -2622,8 +2606,7 @@ export const opencodeHarness: Harness = {
|
|
|
2622
2606
|
headless: false,
|
|
2623
2607
|
events: OPENCODE_EVENTS,
|
|
2624
2608
|
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
2625
|
-
// socket the launch env hands it
|
|
2626
|
-
// parse-confirmed write) and socket-listener liveness are reused verbatim — no opencode transport code.
|
|
2609
|
+
// socket the launch env hands it, so the shared reply poke and socket-listener liveness are reused verbatim.
|
|
2627
2610
|
ownsRendezvous: true,
|
|
2628
2611
|
paneTitleIsSelfSummary: false, // opencode's TUI title is not the agent's live task self-summary → headline uses the prompt
|
|
2629
2612
|
launchCmd: (_id, _rt, cmd) => opencodeLaunchCommand(opencodeBaseCmd(cmd)), // the tail-branching script (prompt vs --resume/--continue marker)
|
|
@@ -2655,7 +2638,7 @@ export const opencodeHarness: Harness = {
|
|
|
2655
2638
|
// load still reads honestly from the process signal instead of a false offline.
|
|
2656
2639
|
liveness: socketListenerOrPidAliveLiveness,
|
|
2657
2640
|
leafOwnerNeedle: (rec) => rec.session,
|
|
2658
|
-
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
2641
|
+
deliver: (rec, text) => deliverViaRendezvous(rec.session, text, rec.mid),
|
|
2659
2642
|
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2660
2643
|
coldRuntime: async () => ({ ok: true }),
|
|
2661
2644
|
// owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
|
|
@@ -2681,6 +2664,7 @@ export const opencodeHeadlessHarness: Harness = {
|
|
|
2681
2664
|
return deliverViaSocketOrWake(
|
|
2682
2665
|
rec.session,
|
|
2683
2666
|
text,
|
|
2667
|
+
rec.mid,
|
|
2684
2668
|
() => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)),
|
|
2685
2669
|
`opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
|
|
2686
2670
|
)
|