spexcode 0.5.2 → 0.5.4
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/README.md +1 -1
- package/package.json +1 -1
- package/spec-cli/bin/spex.mjs +25 -1
- package/spec-cli/hooks/dispatch.sh +24 -1
- package/spec-cli/src/anchors.ts +192 -58
- package/spec-cli/src/attach.ts +11 -8
- package/spec-cli/src/claude-headless.ts +13 -52
- package/spec-cli/src/cli.ts +209 -71
- package/spec-cli/src/client.ts +143 -5
- package/spec-cli/src/gateway-hub.ts +2 -6
- package/spec-cli/src/gateway.ts +96 -11
- package/spec-cli/src/git.ts +1395 -96
- package/spec-cli/src/graph.ts +21 -17
- package/spec-cli/src/graphCache.ts +405 -61
- package/spec-cli/src/graphStream.ts +581 -182
- package/spec-cli/src/guide.ts +15 -4
- package/spec-cli/src/harness.ts +1133 -71
- package/spec-cli/src/headless-controller.ts +63 -0
- package/spec-cli/src/help.ts +120 -64
- package/spec-cli/src/host-resources.ts +698 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +73 -15
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +189 -41
- package/spec-cli/src/lint.ts +152 -27
- package/spec-cli/src/localIssues.ts +5 -1
- package/spec-cli/src/maintenance-wrapper.ts +276 -0
- package/spec-cli/src/materialize.ts +6 -7
- package/spec-cli/src/opencode-headless.ts +4 -8
- package/spec-cli/src/pi-headless.ts +17 -60
- package/spec-cli/src/process-identity.ts +174 -0
- package/spec-cli/src/project-store.ts +17 -0
- package/spec-cli/src/pty-bridge.ts +6 -3
- package/spec-cli/src/reviews.ts +17 -13
- package/spec-cli/src/runtime-ownership.ts +88 -0
- package/spec-cli/src/session-maintenance.ts +750 -0
- package/spec-cli/src/session-timeline.ts +6 -6
- package/spec-cli/src/sessions.ts +1251 -208
- package/spec-cli/src/sh.ts +3 -0
- package/spec-cli/src/source-files.ts +27 -2
- package/spec-cli/src/specs.ts +58 -25
- package/spec-cli/src/supervise.ts +31 -11
- package/spec-cli/src/uninstall.ts +7 -6
- package/spec-cli/templates/hooks/commit-msg +14 -0
- package/spec-cli/templates/hooks/pre-commit +29 -15
- package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
- package/spec-cli/templates/hooks/reference-transaction +105 -0
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
- package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
- package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
- package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
- package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
- package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
- package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
- package/spec-dashboard/dist/assets/{PageScroll-CadAKuSy.js → PageScroll-CAY4S4g4.js} +1 -1
- package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
- package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
- package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
- package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
- package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
- package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
- package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
- package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
- package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
- package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
- package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
- package/spec-dashboard/dist/index.html +5 -4
- package/spec-dashboard/src/reviewFilters.js +46 -8
- package/spec-dashboard/src/session.js +64 -9
- package/spec-eval/src/cli.ts +144 -32
- package/spec-eval/src/evaltab.ts +16 -9
- package/spec-eval/src/freshness.ts +336 -54
- package/spec-eval/src/scenariofresh.ts +51 -1
- package/spec-eval/src/scenarios.ts +324 -25
- package/spec-eval/src/sessioneval.ts +969 -173
- package/spec-cli/src/message-stream.ts +0 -147
- package/spec-dashboard/dist/assets/Dashboard-CTAuTyZ3.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-KbMMownG.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-DmyLb9Rj.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-D2RZGt4Z.js +0 -2
- package/spec-dashboard/dist/assets/Modal-3brXUhM0.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-DU3x4Y8l.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-BtrzlOPs.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BWH5O0jh.js +0 -13
- package/spec-dashboard/dist/assets/Settings-COgdKTJB.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-DQ21GSJK.js +0 -1
- package/spec-dashboard/dist/assets/index-D6HBvKkJ.js +0 -41
- package/spec-dashboard/dist/assets/index-DFdlYy4H.css +0 -1
package/spec-cli/src/harness.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync } from 'node:fs'
|
|
2
|
-
import { join } from 'node:path'
|
|
1
|
+
import { writeFileSync, readFileSync, existsSync, mkdirSync, rmSync, readdirSync, statSync } from 'node:fs'
|
|
2
|
+
import { join, dirname } from 'node:path'
|
|
3
3
|
import { homedir, tmpdir } from 'node:os'
|
|
4
4
|
import { createHash, randomBytes } from 'node:crypto'
|
|
5
5
|
import { createConnection, type Socket } from 'node:net'
|
|
@@ -13,8 +13,10 @@ import { claudeHeadlessLaunchCommand, claudeHeadlessSock, deliverViaClaudeHeadle
|
|
|
13
13
|
import { codexHeadlessLaunchCommand } from './codex-headless.js'
|
|
14
14
|
import { opencodeHeadlessLaunchCommand, spawnOpenCodeHeadlessTurn } from './opencode-headless.js'
|
|
15
15
|
import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from './pi-headless.js'
|
|
16
|
-
import { runtimeRoot, mainCheckout, readConfig } from './layout.js'
|
|
16
|
+
import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './layout.js'
|
|
17
17
|
import { git } from './git.js'
|
|
18
|
+
import { shQuote } from './sh.js'
|
|
19
|
+
import { detachedRuntimeGenerationToken, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
18
20
|
|
|
19
21
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
20
22
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -29,7 +31,12 @@ import { git } from './git.js'
|
|
|
29
31
|
// (materialize writes every harness's artifacts).
|
|
30
32
|
|
|
31
33
|
export type HarnessId = 'claude' | 'codex' | 'opencode' | 'pi' | 'claude-headless' | 'codex-headless' | 'opencode-headless' | 'pi-headless'
|
|
32
|
-
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null }
|
|
34
|
+
export type HarnessLivenessRecord = { session: string; harnessSessionId?: string | null; stopped?: boolean; archived?: boolean }
|
|
35
|
+
export type HarnessLaunchReadyRecord = HarnessLivenessRecord & { governed?: boolean; runtimeDir: string }
|
|
36
|
+
export type HarnessLaunchReadinessFence = {
|
|
37
|
+
readonly proof: Readonly<Record<string, unknown>>
|
|
38
|
+
validate(current: () => HarnessLaunchReadyRecord | null): Promise<boolean>
|
|
39
|
+
}
|
|
33
40
|
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
34
41
|
// the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
|
|
35
42
|
// one whole-box pid→(ppid, comm) table (a single `ps` spawn).
|
|
@@ -42,6 +49,86 @@ export type HarnessLivenessRecord = { session: string; harnessSessionId?: string
|
|
|
42
49
|
// tree-walk, so a box with no codex — or all pid-registered launches — never pays for it.
|
|
43
50
|
export type ProcTable = Map<number, { ppid: number; comm: string }>
|
|
44
51
|
export type PaneProbe = { panePid?: number; procs?: ProcTable; pidAlive?: boolean }
|
|
52
|
+
export type SharedRuntimeDescriptor = {
|
|
53
|
+
key: string
|
|
54
|
+
label: string
|
|
55
|
+
pidFile: string
|
|
56
|
+
receiptFile: string
|
|
57
|
+
// Lightweight project-wide resident census used by read projections. It must return exact loaded IDs without
|
|
58
|
+
// per-thread reads; the full probe remains the resource/lifecycle surface that also reads turn state.
|
|
59
|
+
residency?: () => Promise<{ healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean }>
|
|
60
|
+
// Lifecycle mutation guard is deliberately narrower than the full resource projection: census every loaded
|
|
61
|
+
// ID, but read only the exact governed target when it is loaded, plus both target descendant collections.
|
|
62
|
+
mutationGuard?: (targetReferenceId: string, opts?: { coldReceipt?: unknown }) => Promise<SharedRuntimeMutationGuard>
|
|
63
|
+
probe(): Promise<SharedRuntimeProbe>
|
|
64
|
+
}
|
|
65
|
+
export type SharedRuntimeMutationGuard = {
|
|
66
|
+
healthy: boolean
|
|
67
|
+
referenceIds: string[]
|
|
68
|
+
targetTurnPresence: 'none' | 'idle' | 'active' | 'unknown'
|
|
69
|
+
descendantIds: string[]
|
|
70
|
+
coldTeardownAuthorized?: boolean
|
|
71
|
+
error?: string
|
|
72
|
+
}
|
|
73
|
+
export type SharedRuntimeProbe = {
|
|
74
|
+
healthy: boolean
|
|
75
|
+
references: Array<{
|
|
76
|
+
referenceId: string
|
|
77
|
+
turnPresence: 'idle' | 'active' | 'unknown'
|
|
78
|
+
turnId?: string
|
|
79
|
+
}>
|
|
80
|
+
error?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export type HarnessColdPreflight =
|
|
84
|
+
| { ok: true; alreadyCold?: boolean; receipt?: unknown }
|
|
85
|
+
| { ok: false; reason: string }
|
|
86
|
+
|
|
87
|
+
export type AdapterLoadedReferenceState = {
|
|
88
|
+
healthy: boolean
|
|
89
|
+
loaded: boolean
|
|
90
|
+
error?: string
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
|
|
94
|
+
// once per call, then its result is joined to every record that names that adapter/thread. Product readers must
|
|
95
|
+
// not turn this into one RPC per row: a loaded thread can be externally reloaded after its cold proof was filed.
|
|
96
|
+
export async function adapterLoadedReferenceState(
|
|
97
|
+
records: readonly (HarnessLivenessRecord & { harness?: string })[],
|
|
98
|
+
runtimeDir = runtimeRoot(),
|
|
99
|
+
): Promise<Map<string, AdapterLoadedReferenceState>> {
|
|
100
|
+
const descriptors = new Map<string, SharedRuntimeDescriptor>()
|
|
101
|
+
const recordKeys = new Map<string, string[]>()
|
|
102
|
+
for (const rec of records) {
|
|
103
|
+
if (!rec.harnessSessionId) continue
|
|
104
|
+
const keys = (harnessById(rec.harness || defaultHarness.id).sharedRuntimes?.(runtimeDir) ?? []).map((descriptor) => {
|
|
105
|
+
descriptors.set(descriptor.key, descriptor)
|
|
106
|
+
return descriptor.key
|
|
107
|
+
})
|
|
108
|
+
recordKeys.set(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`, keys)
|
|
109
|
+
}
|
|
110
|
+
const probes = await Promise.all([...descriptors.entries()].map(async ([key, descriptor]) => {
|
|
111
|
+
try {
|
|
112
|
+
const result = descriptor.residency
|
|
113
|
+
? await descriptor.residency()
|
|
114
|
+
: await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }))
|
|
115
|
+
return [key, result] as const
|
|
116
|
+
}
|
|
117
|
+
catch (error) { return [key, { healthy: false, referenceIds: [] as string[], error: (error as Error).message }] as const }
|
|
118
|
+
}))
|
|
119
|
+
const byKey = new Map(probes)
|
|
120
|
+
const result = new Map<string, AdapterLoadedReferenceState>()
|
|
121
|
+
for (const [recordKey, keys] of recordKeys) {
|
|
122
|
+
const refs = keys.map((key) => byKey.get(key)!).filter(Boolean)
|
|
123
|
+
if (!refs.length) continue
|
|
124
|
+
const unhealthy = refs.find((probe) => !probe.healthy)
|
|
125
|
+
const threadId = recordKey.slice(recordKey.indexOf(':') + 1)
|
|
126
|
+
result.set(recordKey, unhealthy
|
|
127
|
+
? { healthy: false, loaded: false, error: unhealthy.error || 'adapter resident-reference census is unhealthy' }
|
|
128
|
+
: { healthy: true, loaded: refs.some((probe) => probe.referenceIds.includes(threadId)) })
|
|
129
|
+
}
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
45
132
|
|
|
46
133
|
export interface Harness {
|
|
47
134
|
readonly id: HarnessId
|
|
@@ -51,9 +138,18 @@ export interface Harness {
|
|
|
51
138
|
// whether the launch command intentionally exits after its first turn instead of owning a resident process.
|
|
52
139
|
// One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
|
|
53
140
|
readonly launchOneShot?: boolean
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
141
|
+
// Adapter-owned runtime shape: headless controllers/shared threads have no interactive TUI leaf to signal.
|
|
142
|
+
readonly runtimeOwnership?: 'leaf' | 'adapter'
|
|
143
|
+
// This launch command may create its project-shared control plane through internal shared-runtime-spawn.
|
|
144
|
+
readonly sharedRuntimeSpawn?: boolean
|
|
145
|
+
// @@@ fatalLaunchOutput - extended regexes matching THIS harness's own report of a launch failure that
|
|
146
|
+
// RUNNING IT AGAIN CANNOT FIX: a conversation that does not exist, a rejected credential, a broken config.
|
|
147
|
+
// A launcher that exits within the boot window tells us only that it exited fast, which is why the transport
|
|
148
|
+
// retries — but when the harness itself named a settled cause, retrying spends a certain failure two more
|
|
149
|
+
// times and buries the one line that explains it. So the transport asks the ADAPTER, and the adapter is the
|
|
150
|
+
// only place a harness's wording is ever matched: product code consumes the verdict (retry / fatal), never
|
|
151
|
+
// the text. A harness that declares none keeps the plain bounded retry.
|
|
152
|
+
readonly fatalLaunchOutput?: readonly string[]
|
|
57
153
|
// the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
|
|
58
154
|
// Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
|
|
59
155
|
// idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
|
|
@@ -89,6 +185,9 @@ export interface Harness {
|
|
|
89
185
|
sessionIdArg(id: string): string
|
|
90
186
|
// the env var the agent's OWN process carries so its `spex …` calls know their session id.
|
|
91
187
|
readonly sessionEnvVar: string
|
|
188
|
+
// transport bootstrap variables scoped to this launch. Rendezvous adapters own their daemon mode + socket;
|
|
189
|
+
// product launch code only composes these with generic session/home env.
|
|
190
|
+
launchEnv(id: string): string[]
|
|
92
191
|
|
|
93
192
|
// --- materialize: shim + contract + trust ([[harness-delivery]]) ---
|
|
94
193
|
// the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
|
|
@@ -148,6 +247,17 @@ export interface Harness {
|
|
|
148
247
|
// to the shell). A missing probe (tmux/ps couldn't report) is not-live. The 'starting' boot
|
|
149
248
|
// grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
|
|
150
249
|
liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
|
|
250
|
+
// A completed launch command is only transport acceptance. An adapter with stronger runtime ownership may
|
|
251
|
+
// keep the caller waiting until the launched conversation is genuinely addressable. The lazy record source
|
|
252
|
+
// lets a one-shot launch publish its native id while readiness is pending. The returned adapter-owned fence
|
|
253
|
+
// names the facts that established readiness and revalidates those SAME facts across the caller's record
|
|
254
|
+
// commit. Null at the deadline is a launch failure; adapters without this seam retain the generic bounded
|
|
255
|
+
// liveness fence.
|
|
256
|
+
launchReady?(current: () => HarnessLaunchReadyRecord | null, deadline: number): Promise<HarnessLaunchReadinessFence | null>
|
|
257
|
+
// Exact leaf ownership evidence consumed by lifecycle teardown. The adapter returns the one argv identity
|
|
258
|
+
// token it registered for this record (session id, harness thread/generation, or null when unprovable);
|
|
259
|
+
// product lifecycle code never branches on harness names to invent this identity.
|
|
260
|
+
leafOwnerNeedle?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
|
|
151
261
|
// deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
|
|
152
262
|
// control socket — an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
|
|
153
263
|
// before it proves a concurrent connect kicked the chunk (the daemon is single-connection) → resend; a wall
|
|
@@ -163,7 +273,24 @@ export interface Harness {
|
|
|
163
273
|
// Remove this harness's ephemeral runtime transport after stop/close. This is the runtime inverse of
|
|
164
274
|
// launch: rendezvous owners unlink rvSock, claude-headless unlinks its control socket, Codex owns no
|
|
165
275
|
// per-session socket. Product teardown calls only this adapter method.
|
|
166
|
-
|
|
276
|
+
// Async because removal is CONDITIONAL on proof: a transport is only ours to remove once its listener is
|
|
277
|
+
// proven dead (see unlinkSocks), and that proof is a connect probe.
|
|
278
|
+
cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>
|
|
279
|
+
// Archive preflight runs BEFORE any leaf signal. It may inspect shared references to refuse an active or
|
|
280
|
+
// unknown target turn, but it must not mutate the shared runtime; coldRuntime is the sole commit primitive.
|
|
281
|
+
// Its optional receipt is opaque adapter authority: product code may only pass the same object back to the
|
|
282
|
+
// stop guard and coldRuntime, never inspect it or synthesize a recursive/archive mode.
|
|
283
|
+
coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<HarnessColdPreflight>
|
|
284
|
+
// A record that is already archived needs a target-only continuing-cold proof. Unlike mutation preflight,
|
|
285
|
+
// this must not thread/read unrelated loaded siblings merely to retire a target whose runtime is absent.
|
|
286
|
+
coldRetirementPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold: true } | { ok: false; reason: string }>
|
|
287
|
+
// Optional cold-storage proof/cleanup. A harness with a per-session loaded reference must remove exactly that
|
|
288
|
+
// reference or return a loud reason; adapters without such a resident reference return {ok:true}.
|
|
289
|
+
coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
290
|
+
restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
291
|
+
// Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
|
|
292
|
+
// references and protect a sibling-owned control plane without learning harness command names.
|
|
293
|
+
sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
|
|
167
294
|
// the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
|
|
168
295
|
// socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
|
|
169
296
|
// the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
|
|
@@ -214,12 +341,36 @@ export type HarnessArtifacts = { skills: readonly string[]; agents: readonly str
|
|
|
214
341
|
// sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
|
|
215
342
|
// that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
|
|
216
343
|
// `{"type":"reply","text":"…"}\n` injects + submits the text as a prompt — no PTY typing, so multi-line input
|
|
217
|
-
// and Enters can't be corrupted the way `tmux send-keys` was.
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
222
|
-
|
|
344
|
+
// and Enters can't be corrupted the way `tmux send-keys` was. It lives in tmpdir tied to the claude process, so
|
|
345
|
+
// no extra lifecycle. liveness CONNECTS to it (a live LISTENER, not merely the file — see rendezvousListening);
|
|
346
|
+
// deliver writes to it.
|
|
347
|
+
//
|
|
348
|
+
// The path is a LAUNCH-TIME FACT, recorded — not a formula every consumer re-derives. The id alone was not
|
|
349
|
+
// enough to name it: `SPEXCODE_HOME` scopes the store and `SPEXCODE_TMUX` scopes the tmux server, so two
|
|
350
|
+
// worlds on one box (a fixture, a migration, a copied record) can hold the same session id — and while the
|
|
351
|
+
// path ignored that scoping, they SHARED this socket. That is how an isolated teardown reached out and
|
|
352
|
+
// stranded a live production agent, and delivery would have crossed the same way. So the path a launch hands
|
|
353
|
+
// its agent is derived from the runtime the session belongs to (`runtimeRoot()` — the same identity that
|
|
354
|
+
// scopes its store) and STAMPED beside the record, exactly like `agent.pid`: a launch-time fact, readable by
|
|
355
|
+
// everyone who needs to reach that agent afterwards. Recording it (rather than re-deriving) also means the
|
|
356
|
+
// derivation can change again without stranding anything already running.
|
|
357
|
+
// `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
|
|
358
|
+
// the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
|
|
359
|
+
export const legacyRvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
|
|
360
|
+
export const scopedRvSock = (id: string, dir = runtimeRoot()) =>
|
|
361
|
+
join(tmpdir(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`)
|
|
362
|
+
const rvStamp = (id: string) => sessionArtifactPath(id, 'rv.path')
|
|
363
|
+
export const rvSock = (id: string): string => {
|
|
364
|
+
try { return readFileSync(rvStamp(id), 'utf8').trim() || legacyRvSock(id) } catch { return legacyRvSock(id) }
|
|
365
|
+
}
|
|
366
|
+
// launch's half: derive this session's socket in ITS runtime and record it, so every later reader (launch env,
|
|
367
|
+
// liveness probe, delivery, teardown) reads the one path the agent actually bound.
|
|
368
|
+
export function stampRvSock(id: string, dir = runtimeRoot()): string {
|
|
369
|
+
const path = scopedRvSock(id, dir)
|
|
370
|
+
mkdirSync(dirname(rvStamp(id)), { recursive: true })
|
|
371
|
+
writeFileSync(rvStamp(id), path)
|
|
372
|
+
return path
|
|
373
|
+
}
|
|
223
374
|
|
|
224
375
|
// @@@ rendezvousListening - the LISTENER check that IS claude's liveness truth ([[state]], [[harness-adapter]]).
|
|
225
376
|
// A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
|
|
@@ -237,7 +388,7 @@ export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
|
|
|
237
388
|
// wedged/thrashed path. Never throws.
|
|
238
389
|
export type ListenerProbe = 'live' | 'dead' | 'unproven'
|
|
239
390
|
const PROVEN_DEAD = new Set(['ECONNREFUSED', 'ENOENT'])
|
|
240
|
-
export function
|
|
391
|
+
export function listenerAt(path: string, timeoutMs = 800): Promise<ListenerProbe> {
|
|
241
392
|
return new Promise((resolve) => {
|
|
242
393
|
let settled = false
|
|
243
394
|
let c: ReturnType<typeof createConnection> | undefined
|
|
@@ -249,11 +400,12 @@ export function rendezvousListening(id: string, timeoutMs = 800): Promise<Listen
|
|
|
249
400
|
resolve(v)
|
|
250
401
|
}
|
|
251
402
|
const timer = setTimeout(() => done('unproven'), timeoutMs)
|
|
252
|
-
try { c = createConnection({ path
|
|
403
|
+
try { c = createConnection({ path }) } catch { return done('unproven') }
|
|
253
404
|
c.on('connect', () => done('live'))
|
|
254
405
|
c.on('error', (e) => done(PROVEN_DEAD.has((e as NodeJS.ErrnoException).code ?? '') ? 'dead' : 'unproven'))
|
|
255
406
|
})
|
|
256
407
|
}
|
|
408
|
+
export const rendezvousListening = (id: string, timeoutMs = 800): Promise<ListenerProbe> => listenerAt(rvSock(id), timeoutMs)
|
|
257
409
|
// The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
|
|
258
410
|
// dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
|
|
259
411
|
// dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
|
|
@@ -277,9 +429,29 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
|
|
|
277
429
|
return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
|
|
278
430
|
}
|
|
279
431
|
export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
432
|
+
export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
|
|
433
|
+
type CodexRuntimeGenerationProof = Readonly<{
|
|
434
|
+
identity: VerifiedDetachedRuntime
|
|
435
|
+
socket: Readonly<{ path: string; dev: number; ino: number }>
|
|
436
|
+
}>
|
|
437
|
+
function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
|
|
438
|
+
try {
|
|
439
|
+
const pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
|
|
440
|
+
const detached = verifyDetachedRuntime(pid, codexAppServerReceipt(dir))
|
|
441
|
+
const socketPath = codexAppServerSock(dir)
|
|
442
|
+
const socket = statSync(socketPath)
|
|
443
|
+
if (!(pid > 0) || !detached.ok || !socket.isSocket()) return null
|
|
444
|
+
return Object.freeze({
|
|
445
|
+
identity: detached.identity,
|
|
446
|
+
socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
|
|
447
|
+
})
|
|
448
|
+
} catch { return null }
|
|
449
|
+
}
|
|
450
|
+
const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
|
|
451
|
+
`${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
|
|
452
|
+
function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
|
|
453
|
+
const proof = codexRuntimeGenerationProof(dir)
|
|
454
|
+
return proof ? codexRuntimeGenerationToken(proof) : null
|
|
283
455
|
}
|
|
284
456
|
|
|
285
457
|
// the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
|
|
@@ -369,6 +541,18 @@ export async function deliverViaRendezvous(id: string, text: string, wallMs?: nu
|
|
|
369
541
|
return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× — prompt NOT delivered, retry the send` }
|
|
370
542
|
}
|
|
371
543
|
|
|
544
|
+
export async function deliverViaSocketOrWake(
|
|
545
|
+
id: string,
|
|
546
|
+
text: string,
|
|
547
|
+
coldWake: () => Promise<DispatchResult>,
|
|
548
|
+
unprovenError: string,
|
|
549
|
+
): Promise<DispatchResult> {
|
|
550
|
+
const probe = await rendezvousListening(id)
|
|
551
|
+
if (probe === 'live') return deliverViaRendezvous(id, text)
|
|
552
|
+
if (probe === 'unproven') return { ok: false, error: unprovenError }
|
|
553
|
+
return coldWake()
|
|
554
|
+
}
|
|
555
|
+
|
|
372
556
|
type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
|
|
373
557
|
|
|
374
558
|
// The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
|
|
@@ -463,10 +647,17 @@ export async function reportHeadlessTurnExit(id: string, harness: string, code:
|
|
|
463
647
|
}
|
|
464
648
|
}
|
|
465
649
|
|
|
466
|
-
export function headlessTurnFailureShell(harness: string): string {
|
|
467
|
-
return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc" || true`
|
|
650
|
+
export function headlessTurnFailureShell(harness: string, swallow = true): string {
|
|
651
|
+
return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc"${swallow ? ' || true' : ''}`
|
|
468
652
|
}
|
|
469
|
-
|
|
653
|
+
// @@@ sessionIdentityEnvVars - every environment variable that names ONE session: the launch-injected record
|
|
654
|
+
// id plus each adapter's own `sessionEnvVar`. Adapter-derived, so a new harness needs no edit here. A
|
|
655
|
+
// per-session process is entitled to carry them; a SHARED, project-scoped daemon must not — see the app-server
|
|
656
|
+
// spawn below.
|
|
657
|
+
export function sessionIdentityEnvVars(): string[] {
|
|
658
|
+
return [...new Set(['SPEXCODE_SESSION_ID', ...HARNESSES.map((h) => h.sessionEnvVar)])].filter(Boolean)
|
|
659
|
+
}
|
|
660
|
+
export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
|
|
470
661
|
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
|
|
471
662
|
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
472
663
|
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
@@ -478,12 +669,14 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
478
669
|
const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
|
|
479
670
|
const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
|
|
480
671
|
const pid = codexAppServerPid(dir)
|
|
672
|
+
const receipt = codexAppServerReceipt(dir)
|
|
481
673
|
const log = join(dir, 'codex-app-server.log')
|
|
482
674
|
const lock = join(dir, 'codex-app-server.lock')
|
|
483
675
|
const script = [
|
|
484
676
|
`dir=${shQuote(dir)}`,
|
|
485
677
|
`sock=${shQuote(sock)}`,
|
|
486
678
|
`pid=${shQuote(pid)}`,
|
|
679
|
+
`receipt=${shQuote(receipt)}`,
|
|
487
680
|
`log=${shQuote(log)}`,
|
|
488
681
|
`lock=${shQuote(lock)}`,
|
|
489
682
|
// codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
|
|
@@ -519,12 +712,25 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
519
712
|
// removed, the daemon's cwd becomes a DELETED dir, and codex then fails EVERY new thread's config load with
|
|
520
713
|
// `failed to load configuration: No such file or directory` — bricking codex launch for the whole project
|
|
521
714
|
// until the daemon is killed. Running it from "$dir" (which never gets deleted) makes it deletion-proof.
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
'
|
|
715
|
+
// For the SAME reason it must carry no session IDENTITY: it is started by whichever session happened to
|
|
716
|
+
// launch first and then serves every later thread, whose tool shells inherit its env — so a baked
|
|
717
|
+
// SPEXCODE_SESSION_ID (or any adapter's `sessionEnvVar`) is a stale lie for every session but one, and
|
|
718
|
+
// still a lie after that session closes and its record is swept (measured: daemons here running for days
|
|
719
|
+
// under a long-gone session's id). Everything downstream that resolves identity from the env then
|
|
720
|
+
// mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
|
|
721
|
+
// inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
|
|
722
|
+
// The adapter launches its shared control plane in a new OS process group + session. `nohup` alone was not
|
|
723
|
+
// a boundary: the Codex Node launcher reset signal handling and died with the tmux pane. The internal helper
|
|
724
|
+
// uses child_process detached=true, then the process adapter publishes a private receipt only after proving
|
|
725
|
+
// exact PID/start + PGID (and Linux SID). Every consumer re-verifies that same receipt.
|
|
726
|
+
` ( 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; }`,
|
|
525
727
|
' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
|
|
526
728
|
'fi',
|
|
527
729
|
'rmdir "$lockd" 2>/dev/null',
|
|
730
|
+
// The delegated bearer arrived only through fd 9. The shared-spawn helper consumed it (or the already-live
|
|
731
|
+
// socket made it unnecessary); close and scrub the channel before the per-session Codex client starts.
|
|
732
|
+
'[ "${SPEXCODE_MAINTENANCE_DELEGATE_FD:-}" != "9" ] || exec 9<&-',
|
|
733
|
+
'unset SPEXCODE_MAINTENANCE_DELEGATE_FD SPEXCODE_MAINTENANCE_SESSION_ID',
|
|
528
734
|
// TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
|
|
529
735
|
// codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
|
|
530
736
|
// thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
|
|
@@ -550,7 +756,11 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
550
756
|
]),
|
|
551
757
|
`fi`,
|
|
552
758
|
`[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
|
|
553
|
-
|
|
759
|
+
// The visible TUI is the OTHER entry point that creates an execution context for this session (a fresh
|
|
760
|
+
// launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
|
|
761
|
+
// the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
|
|
762
|
+
// creates a context stamps that context's record id, and nothing downstream re-derives it.
|
|
763
|
+
...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --remote unix://"$sock" resume "$tid"`] : []),
|
|
554
764
|
].join('\n')
|
|
555
765
|
return `bash -lc ${shQuote(script)} spexcode-codex`
|
|
556
766
|
}
|
|
@@ -606,6 +816,434 @@ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => vo
|
|
|
606
816
|
const WS_UPGRADE = (key: string) => `GET /rpc HTTP/1.1\r\nHost: localhost\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: ${key}\r\n\r\n`
|
|
607
817
|
const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
|
|
608
818
|
|
|
819
|
+
// Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
820
|
+
// defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
|
|
821
|
+
type CodexGenerationFence = { dir: string; generation: string }
|
|
822
|
+
function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive', threadId: string, fence?: CodexGenerationFence): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
823
|
+
const generationError = () => fence && codexRuntimeGeneration(fence.dir) !== fence.generation
|
|
824
|
+
? `Codex ${method} refused because the shared app-server generation changed`
|
|
825
|
+
: null
|
|
826
|
+
const before = generationError()
|
|
827
|
+
if (before) return Promise.resolve({ ok: false, error: before })
|
|
828
|
+
return new Promise((resolve) => {
|
|
829
|
+
const conn: Socket = createConnection(sock)
|
|
830
|
+
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
831
|
+
let upgraded = false, settled = false
|
|
832
|
+
const done = (r: { ok: true } | { ok: false; error: string }) => {
|
|
833
|
+
if (settled) return
|
|
834
|
+
settled = true
|
|
835
|
+
clearTimeout(timer)
|
|
836
|
+
try { conn.destroy() } catch {}
|
|
837
|
+
resolve(r)
|
|
838
|
+
}
|
|
839
|
+
// thread/archive may wait up to 10s in shutdown_and_wait before the server commits; keep a margin so a
|
|
840
|
+
// legitimate late response is not turned into an early commit-unknown race.
|
|
841
|
+
const timer = setTimeout(() => done({ ok: false, error: generationError() || `Codex ${method} timed out after 15s` }), 15000)
|
|
842
|
+
conn.on('error', (e) => done({ ok: false, error: generationError() || `Codex ${method} connection failed: ${rpcError(e)}` }))
|
|
843
|
+
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${method}` }) })
|
|
844
|
+
const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
|
|
845
|
+
conn.on('connect', () => {
|
|
846
|
+
const changed = generationError()
|
|
847
|
+
if (changed) return done({ ok: false, error: changed })
|
|
848
|
+
conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
|
|
849
|
+
})
|
|
850
|
+
const handle = (json: string) => {
|
|
851
|
+
let m: JsonRpc
|
|
852
|
+
try { m = JSON.parse(json) } catch { return }
|
|
853
|
+
if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}` })
|
|
854
|
+
if (m.id === 1 && m.result) {
|
|
855
|
+
const changed = generationError()
|
|
856
|
+
if (changed) return done({ ok: false, error: changed })
|
|
857
|
+
send({ method: 'initialized', params: {} })
|
|
858
|
+
return send({ id: 2, method, params: { threadId } })
|
|
859
|
+
}
|
|
860
|
+
if (m.id === 2 && m.result) {
|
|
861
|
+
const changed = generationError()
|
|
862
|
+
return changed ? done({ ok: false, error: changed }) : done({ ok: true })
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
conn.on('data', (chunk: Buffer) => {
|
|
866
|
+
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
867
|
+
if (!upgraded) {
|
|
868
|
+
const i = fs.buf.indexOf('\r\n\r\n')
|
|
869
|
+
if (i < 0) return
|
|
870
|
+
const head = fs.buf.slice(0, i).toString('utf8')
|
|
871
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}` })
|
|
872
|
+
upgraded = true
|
|
873
|
+
fs.buf = fs.buf.slice(i + 4)
|
|
874
|
+
send(wsInitialize)
|
|
875
|
+
}
|
|
876
|
+
if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${method}` })
|
|
877
|
+
})
|
|
878
|
+
})
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
type CodexPagedIdsResult = { ok: true; ids: string[] } | { ok: false; error: string }
|
|
882
|
+
// Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
|
|
883
|
+
// entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
|
|
884
|
+
// supplies every protocol source kind explicitly for its thread/list calls.
|
|
885
|
+
export const CODEX_THREAD_SOURCE_KINDS = [
|
|
886
|
+
'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
|
|
887
|
+
'subAgentThreadSpawn', 'subAgentOther', 'unknown',
|
|
888
|
+
] as const
|
|
889
|
+
function codexPagedIds(
|
|
890
|
+
sock: string,
|
|
891
|
+
method: 'thread/list' | 'thread/loaded/list',
|
|
892
|
+
params: Record<string, unknown>,
|
|
893
|
+
extractId: (item: unknown) => string | null,
|
|
894
|
+
label: string,
|
|
895
|
+
onItem?: (item: unknown) => void,
|
|
896
|
+
): Promise<CodexPagedIdsResult> {
|
|
897
|
+
return new Promise((resolve) => {
|
|
898
|
+
const conn: Socket = createConnection(sock)
|
|
899
|
+
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
900
|
+
let upgraded = false, settled = false, requestId = 2, cursor: string | null = null
|
|
901
|
+
const ids = new Set<string>()
|
|
902
|
+
const done = (result: CodexPagedIdsResult) => {
|
|
903
|
+
if (settled) return
|
|
904
|
+
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
905
|
+
}
|
|
906
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out` }), 5000)
|
|
907
|
+
conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
|
|
908
|
+
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
|
|
909
|
+
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
910
|
+
const requestPage = () => send({ id: requestId, method, params: { ...params, ...(cursor ? { cursor } : {}), limit: 100 } })
|
|
911
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
912
|
+
const handle = (json: string) => {
|
|
913
|
+
let message: JsonRpc
|
|
914
|
+
try { message = JSON.parse(json) } catch { return }
|
|
915
|
+
if (message.error) return done({ ok: false, error: `Codex ${label} failed: ${message.error.message || JSON.stringify(message.error)}` })
|
|
916
|
+
if (message.id === 1 && message.result) { send({ method: 'initialized', params: {} }); return requestPage() }
|
|
917
|
+
if (message.id !== requestId || !message.result) return
|
|
918
|
+
const page = message.result as { data?: unknown; nextCursor?: unknown }
|
|
919
|
+
if (Array.isArray(page.data)) for (const item of page.data) {
|
|
920
|
+
onItem?.(item)
|
|
921
|
+
const id = extractId(item)
|
|
922
|
+
if (typeof id === 'string') ids.add(id)
|
|
923
|
+
}
|
|
924
|
+
cursor = typeof page.nextCursor === 'string' && page.nextCursor ? page.nextCursor : null
|
|
925
|
+
if (!cursor) return done({ ok: true, ids: [...ids] })
|
|
926
|
+
requestId++
|
|
927
|
+
requestPage()
|
|
928
|
+
}
|
|
929
|
+
conn.on('data', (chunk: Buffer) => {
|
|
930
|
+
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
931
|
+
if (!upgraded) {
|
|
932
|
+
const i = fs.buf.indexOf('\r\n\r\n')
|
|
933
|
+
if (i < 0) return
|
|
934
|
+
const head = fs.buf.slice(0, i).toString('utf8')
|
|
935
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused loaded-reference census: ${head.split('\r\n')[0]}` })
|
|
936
|
+
upgraded = true; fs.buf = fs.buf.slice(i + 4)
|
|
937
|
+
send(wsInitialize)
|
|
938
|
+
}
|
|
939
|
+
if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${label}` })
|
|
940
|
+
})
|
|
941
|
+
})
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
// Lightweight resident census: unlike the full shared-runtime probe, this scans only paginated manager IDs
|
|
945
|
+
// and never issues thread/read includeTurns for each loaded reference.
|
|
946
|
+
export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true; referenceIds: string[] } | { ok: false; error: string }> {
|
|
947
|
+
const result = await codexPagedIds(sock, 'thread/loaded/list', {}, (item) => {
|
|
948
|
+
if (typeof item === 'string') return item
|
|
949
|
+
const value = item as { id?: unknown; threadId?: unknown } | null
|
|
950
|
+
return typeof value?.id === 'string' ? value.id : typeof value?.threadId === 'string' ? value.threadId : null
|
|
951
|
+
}, 'loaded-reference census')
|
|
952
|
+
return result.ok ? { ok: true, referenceIds: result.ids } : result
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }> {
|
|
956
|
+
return new Promise((resolve) => {
|
|
957
|
+
const conn: Socket = createConnection(sock)
|
|
958
|
+
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
959
|
+
let upgraded = false, settled = false
|
|
960
|
+
const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }) => {
|
|
961
|
+
if (settled) return
|
|
962
|
+
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
963
|
+
}
|
|
964
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} read timed out after 5000ms` }), 5000)
|
|
965
|
+
conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} read failed: ${rpcError(error)}` }))
|
|
966
|
+
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` }) })
|
|
967
|
+
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
968
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
969
|
+
const handle = (json: string) => {
|
|
970
|
+
let message: JsonRpc
|
|
971
|
+
try { message = JSON.parse(json) } catch { return }
|
|
972
|
+
if (message.error) return done({ ok: false, error: `Codex target thread ${threadId} read failed: ${message.error.message || JSON.stringify(message.error)}` })
|
|
973
|
+
if (message.id === 1 && message.result) {
|
|
974
|
+
send({ method: 'initialized', params: {} })
|
|
975
|
+
return send({ id: 2, method: 'thread/read', params: { threadId, includeTurns: true } })
|
|
976
|
+
}
|
|
977
|
+
if (message.id !== 2 || !message.result) return
|
|
978
|
+
const turns = (message.result as { thread?: { turns?: unknown } }).thread?.turns
|
|
979
|
+
if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
|
|
980
|
+
return done({ ok: true, turnPresence: activeTurnIdFromThread(message.result) ? 'active' : 'idle' })
|
|
981
|
+
}
|
|
982
|
+
conn.on('data', (chunk: Buffer) => {
|
|
983
|
+
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
984
|
+
if (!upgraded) {
|
|
985
|
+
const i = fs.buf.indexOf('\r\n\r\n')
|
|
986
|
+
if (i < 0) return
|
|
987
|
+
const head = fs.buf.slice(0, i).toString('utf8')
|
|
988
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused target thread ${threadId} read: ${head.split('\r\n')[0]}` })
|
|
989
|
+
upgraded = true; fs.buf = fs.buf.slice(i + 4); send(wsInitialize)
|
|
990
|
+
}
|
|
991
|
+
if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` })
|
|
992
|
+
})
|
|
993
|
+
})
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
// The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
|
|
997
|
+
// a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
|
|
998
|
+
export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
|
|
999
|
+
return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
type CodexThreadCollectionResult =
|
|
1003
|
+
| { ok: true; ids: string[]; parentById: Map<string, string | null> }
|
|
1004
|
+
| { ok: false; error: string }
|
|
1005
|
+
|
|
1006
|
+
function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
|
|
1007
|
+
const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
|
|
1008
|
+
? params.sourceKinds
|
|
1009
|
+
: [...CODEX_THREAD_SOURCE_KINDS]
|
|
1010
|
+
const parentById = new Map<string, string | null>()
|
|
1011
|
+
const conflictingParents = new Set<string>()
|
|
1012
|
+
return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
|
|
1013
|
+
if (typeof item === 'string') return item
|
|
1014
|
+
const id = (item as { id?: unknown } | null)?.id
|
|
1015
|
+
return typeof id === 'string' ? id : null
|
|
1016
|
+
}, 'thread/list', (item) => {
|
|
1017
|
+
if (!item || typeof item !== 'object') return
|
|
1018
|
+
const row = item as { id?: unknown; parentThreadId?: unknown }
|
|
1019
|
+
if (typeof row.id !== 'string') return
|
|
1020
|
+
const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
|
|
1021
|
+
if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
|
|
1022
|
+
parentById.set(row.id, parent)
|
|
1023
|
+
}).then((result) => {
|
|
1024
|
+
if (!result.ok) return result
|
|
1025
|
+
if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
|
|
1026
|
+
return { ...result, parentById }
|
|
1027
|
+
})
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
|
|
1031
|
+
const generationBefore = codexRuntimeGeneration(dir)
|
|
1032
|
+
if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
|
|
1033
|
+
const sock = codexAppServerSock(dir)
|
|
1034
|
+
const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
|
|
1035
|
+
codexLoadedReferenceIds(sock),
|
|
1036
|
+
codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1037
|
+
codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1038
|
+
])
|
|
1039
|
+
const referenceIds = loaded.ok ? loaded.referenceIds : []
|
|
1040
|
+
const descendantIds = activeDescendants.ok && archivedDescendants.ok
|
|
1041
|
+
? [...new Set([...activeDescendants.ids, ...archivedDescendants.ids])]
|
|
1042
|
+
: []
|
|
1043
|
+
if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
|
|
1044
|
+
if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
|
|
1045
|
+
if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
|
|
1046
|
+
let targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = 'none'
|
|
1047
|
+
if (referenceIds.includes(threadId)) {
|
|
1048
|
+
const target = await codexTargetTurnPresence(sock, threadId)
|
|
1049
|
+
if (!target.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: target.error }
|
|
1050
|
+
targetTurnPresence = target.turnPresence
|
|
1051
|
+
}
|
|
1052
|
+
if (codexRuntimeGeneration(dir) !== generationBefore)
|
|
1053
|
+
return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
|
|
1054
|
+
return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const CODEX_COLD_PLAN = Symbol('codex-cold-plan')
|
|
1058
|
+
type CodexColdPlan = Readonly<{
|
|
1059
|
+
[CODEX_COLD_PLAN]: true
|
|
1060
|
+
kind: 'codex-cold-subtree-v1'
|
|
1061
|
+
threadId: string
|
|
1062
|
+
generation: string
|
|
1063
|
+
guard: SharedRuntimeMutationGuard
|
|
1064
|
+
descendantIds: readonly string[]
|
|
1065
|
+
parentEdges: readonly (readonly [string, string])[]
|
|
1066
|
+
subtreeIds: readonly string[]
|
|
1067
|
+
activeIds: readonly string[]
|
|
1068
|
+
archivedIds: readonly string[]
|
|
1069
|
+
}>
|
|
1070
|
+
type CodexColdPreflight = { ok: true; alreadyCold?: boolean; receipt: CodexColdPlan } | { ok: false; reason: string }
|
|
1071
|
+
|
|
1072
|
+
const sameIdSet = (left: readonly string[], right: readonly string[]) =>
|
|
1073
|
+
left.length === right.length && left.every((id) => right.includes(id))
|
|
1074
|
+
|
|
1075
|
+
const sameParentEdges = (left: readonly (readonly [string, string])[], right: readonly (readonly [string, string])[]) =>
|
|
1076
|
+
left.length === right.length && left.every(([id, parent]) => right.some(([otherId, otherParent]) => id === otherId && parent === otherParent))
|
|
1077
|
+
|
|
1078
|
+
const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
|
|
1079
|
+
if (!value || typeof value !== 'object') return false
|
|
1080
|
+
const plan = value as Partial<CodexColdPlan>
|
|
1081
|
+
return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
|
|
1082
|
+
typeof plan.generation === 'string' && Array.isArray(plan.descendantIds) &&
|
|
1083
|
+
Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
|
|
1084
|
+
Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string): Promise<CodexColdPreflight> {
|
|
1088
|
+
const generation = expectedGeneration ?? codexRuntimeGeneration(dir)
|
|
1089
|
+
if (!generation || codexRuntimeGeneration(dir) !== generation)
|
|
1090
|
+
return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
|
|
1091
|
+
const sock = codexAppServerSock(dir)
|
|
1092
|
+
const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
|
|
1093
|
+
codexLoadedReferenceIds(sock),
|
|
1094
|
+
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1095
|
+
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1096
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1097
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1098
|
+
])
|
|
1099
|
+
if (codexRuntimeGeneration(dir) !== generation)
|
|
1100
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
|
|
1101
|
+
if (!loaded.ok) return { ok: false, reason: loaded.error }
|
|
1102
|
+
if (!activeDescendants.ok) return { ok: false, reason: activeDescendants.error }
|
|
1103
|
+
if (!archivedDescendants.ok) return { ok: false, reason: archivedDescendants.error }
|
|
1104
|
+
if (!archivedList.ok) return { ok: false, reason: archivedList.error }
|
|
1105
|
+
if (!activeList.ok) return { ok: false, reason: activeList.error }
|
|
1106
|
+
|
|
1107
|
+
const activeDescendantSet = new Set(activeDescendants.ids)
|
|
1108
|
+
const archivedDescendantSet = new Set(archivedDescendants.ids)
|
|
1109
|
+
const duplicateDescendants = activeDescendants.ids.filter((id) => archivedDescendantSet.has(id))
|
|
1110
|
+
if (duplicateDescendants.length)
|
|
1111
|
+
return { ok: false, reason: `Codex subtree members occur in both active and archived descendant collections (${duplicateDescendants.join(', ')})` }
|
|
1112
|
+
const descendantIds = [...activeDescendants.ids, ...archivedDescendants.ids]
|
|
1113
|
+
if (descendantIds.includes(threadId)) return { ok: false, reason: `Codex target ${threadId} is duplicated in its own descendant closure` }
|
|
1114
|
+
|
|
1115
|
+
const parentById = new Map([...activeDescendants.parentById, ...archivedDescendants.parentById])
|
|
1116
|
+
const depthById = new Map<string, number>()
|
|
1117
|
+
for (const id of descendantIds) {
|
|
1118
|
+
const seen = new Set([id])
|
|
1119
|
+
let cursor = id
|
|
1120
|
+
let depth = 0
|
|
1121
|
+
while (cursor !== threadId) {
|
|
1122
|
+
const next = parentById.get(cursor)
|
|
1123
|
+
if (!next) return { ok: false, reason: `Codex descendant ${id} has no complete parent chain to target ${threadId} (unowned or reassigned)` }
|
|
1124
|
+
if (seen.has(next)) return { ok: false, reason: `Codex descendant ${id} has a cyclic parent chain` }
|
|
1125
|
+
seen.add(next)
|
|
1126
|
+
cursor = next
|
|
1127
|
+
depth++
|
|
1128
|
+
}
|
|
1129
|
+
depthById.set(id, depth)
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
const activeSet = new Set(activeList.ids)
|
|
1133
|
+
const archivedSet = new Set(archivedList.ids)
|
|
1134
|
+
const subtreeIds = [...descendantIds, threadId]
|
|
1135
|
+
for (const id of subtreeIds) {
|
|
1136
|
+
const inActive = activeSet.has(id)
|
|
1137
|
+
const inArchived = archivedSet.has(id)
|
|
1138
|
+
if (!inActive && !inArchived)
|
|
1139
|
+
return { ok: false, reason: `Codex subtree member ${id} is absent from both native collections (unowned or reassigned)` }
|
|
1140
|
+
if (inActive && inArchived)
|
|
1141
|
+
return { ok: false, reason: `Codex subtree member ${id} occurs in both active and archived native collections` }
|
|
1142
|
+
if (id !== threadId) {
|
|
1143
|
+
const expectedActive = activeDescendantSet.has(id)
|
|
1144
|
+
if (inActive !== expectedActive)
|
|
1145
|
+
return { ok: false, reason: `Codex subtree member ${id} changed collection assignment during ownership census` }
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const loadedSet = new Set(loaded.referenceIds)
|
|
1150
|
+
const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
|
|
1151
|
+
const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
|
|
1152
|
+
if (codexRuntimeGeneration(dir) !== generation)
|
|
1153
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
|
|
1154
|
+
for (const { id, state } of turnStates) {
|
|
1155
|
+
if (!state.ok) return { ok: false, reason: state.error }
|
|
1156
|
+
if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
|
|
1157
|
+
if (state.turnPresence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
|
|
1158
|
+
if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
|
|
1162
|
+
const guard: SharedRuntimeMutationGuard = {
|
|
1163
|
+
healthy: true,
|
|
1164
|
+
referenceIds: [...loaded.referenceIds],
|
|
1165
|
+
targetTurnPresence,
|
|
1166
|
+
descendantIds: [...descendantIds],
|
|
1167
|
+
}
|
|
1168
|
+
const activeIds = [...activeDescendants.ids]
|
|
1169
|
+
.sort((left, right) => (depthById.get(right) ?? 0) - (depthById.get(left) ?? 0))
|
|
1170
|
+
.concat(activeSet.has(threadId) ? [threadId] : [])
|
|
1171
|
+
const archivedIds = [...archivedDescendants.ids, ...(archivedSet.has(threadId) ? [threadId] : [])]
|
|
1172
|
+
const parentEdges = descendantIds.map((id) => [id, parentById.get(id)!] as const)
|
|
1173
|
+
const receipt: CodexColdPlan = Object.freeze({
|
|
1174
|
+
[CODEX_COLD_PLAN]: true as const,
|
|
1175
|
+
kind: 'codex-cold-subtree-v1',
|
|
1176
|
+
threadId,
|
|
1177
|
+
generation,
|
|
1178
|
+
guard,
|
|
1179
|
+
descendantIds: Object.freeze([...descendantIds]),
|
|
1180
|
+
parentEdges: Object.freeze(parentEdges),
|
|
1181
|
+
subtreeIds: Object.freeze([...subtreeIds]),
|
|
1182
|
+
activeIds: Object.freeze(activeIds),
|
|
1183
|
+
archivedIds: Object.freeze(archivedIds),
|
|
1184
|
+
})
|
|
1185
|
+
return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
async function codexMutationGuard(
|
|
1189
|
+
threadId: string,
|
|
1190
|
+
dir = runtimeRoot(),
|
|
1191
|
+
opts: { coldReceipt?: unknown } = {},
|
|
1192
|
+
): Promise<SharedRuntimeMutationGuard> {
|
|
1193
|
+
if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir)
|
|
1194
|
+
if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
|
|
1195
|
+
return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' }
|
|
1196
|
+
const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation)
|
|
1197
|
+
if (!current.ok) {
|
|
1198
|
+
const guard = await codexTargetMutationGuard(threadId, dir)
|
|
1199
|
+
return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason }
|
|
1200
|
+
}
|
|
1201
|
+
const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
|
|
1202
|
+
sameParentEdges(opts.coldReceipt.parentEdges, current.receipt.parentEdges) &&
|
|
1203
|
+
sameIdSet(opts.coldReceipt.activeIds, current.receipt.activeIds) &&
|
|
1204
|
+
sameIdSet(opts.coldReceipt.archivedIds, current.receipt.archivedIds)
|
|
1205
|
+
return {
|
|
1206
|
+
...current.receipt.guard,
|
|
1207
|
+
healthy: authorized,
|
|
1208
|
+
coldTeardownAuthorized: authorized,
|
|
1209
|
+
...(authorized ? {} : { error: 'adapter cold teardown receipt no longer matches the target subtree' }),
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
1214
|
+
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
1215
|
+
return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
|
|
1216
|
+
const sock = codexAppServerSock(dir)
|
|
1217
|
+
const [activeBefore, archivedBefore] = await Promise.all([
|
|
1218
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1219
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1220
|
+
])
|
|
1221
|
+
if (!activeBefore.ok || !archivedBefore.ok)
|
|
1222
|
+
return { ok: false, reason: 'archive state is unknown and could not be reconciled' }
|
|
1223
|
+
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
1224
|
+
return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
|
|
1225
|
+
const activeSet = new Set(activeBefore.ids)
|
|
1226
|
+
const archivedSet = new Set(archivedBefore.ids)
|
|
1227
|
+
if (plan.archivedIds.some((id) => !archivedSet.has(id) || activeSet.has(id)))
|
|
1228
|
+
return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' }
|
|
1229
|
+
if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
|
|
1230
|
+
return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' }
|
|
1231
|
+
const fence = { dir, generation: plan.generation }
|
|
1232
|
+
const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id))
|
|
1233
|
+
for (const id of restoreIds) {
|
|
1234
|
+
const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence)
|
|
1235
|
+
if (!restored.ok) return { ok: false, reason: `compensation failed for ${id}: ${restored.error}` }
|
|
1236
|
+
}
|
|
1237
|
+
const [activeAfter, archivedAfter] = await Promise.all([
|
|
1238
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1239
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1240
|
+
])
|
|
1241
|
+
const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir) === plan.generation &&
|
|
1242
|
+
plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
|
|
1243
|
+
plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id))
|
|
1244
|
+
return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' }
|
|
1245
|
+
}
|
|
1246
|
+
|
|
609
1247
|
// Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
|
|
610
1248
|
// id at launch (codexStartThread → stored on the record), this is only the DELIVERY FALLBACK for a pre-existing
|
|
611
1249
|
// session whose id was never stored: it returns the first loaded thread. On a shared per-project server several
|
|
@@ -654,13 +1292,144 @@ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: strin
|
|
|
654
1292
|
})
|
|
655
1293
|
}
|
|
656
1294
|
|
|
1295
|
+
// Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
|
|
1296
|
+
// they are never treated as references by themselves. A loaded thread is a control-plane reference and its
|
|
1297
|
+
// fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
|
|
1298
|
+
export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRuntimeProbe> {
|
|
1299
|
+
const sock = codexAppServerSock(dir)
|
|
1300
|
+
return (async () => {
|
|
1301
|
+
// File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
|
|
1302
|
+
// only a live PID and a live listener establish a resident control plane. This keeps a deliberately absent
|
|
1303
|
+
// root a healthy empty census while leaving live-but-ambiguous roots loud and visible.
|
|
1304
|
+
let pid = 0
|
|
1305
|
+
try { pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim()) } catch { /* absent/stale */ }
|
|
1306
|
+
const pidLive = pid > 0 && !!processStartToken(pid)
|
|
1307
|
+
const listener = await listenerAt(sock, 800)
|
|
1308
|
+
if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
|
|
1309
|
+
if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
|
|
1310
|
+
const generation = codexRuntimeGeneration(dir)
|
|
1311
|
+
if (!generation) return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' }
|
|
1312
|
+
return new Promise<SharedRuntimeProbe>((resolve) => {
|
|
1313
|
+
const conn: Socket = createConnection(sock)
|
|
1314
|
+
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
1315
|
+
const references = new Map<string, SharedRuntimeProbe['references'][number]>()
|
|
1316
|
+
const requests = new Map<number, string>()
|
|
1317
|
+
const loadedRequests = new Set<number>()
|
|
1318
|
+
const loadedIds = new Set<string>()
|
|
1319
|
+
let loadedRequestId = 2
|
|
1320
|
+
let loadedCursor: string | null = null
|
|
1321
|
+
let upgraded = false
|
|
1322
|
+
let settled = false
|
|
1323
|
+
let timer: NodeJS.Timeout
|
|
1324
|
+
const done = (result: SharedRuntimeProbe) => {
|
|
1325
|
+
if (settled) return
|
|
1326
|
+
settled = true
|
|
1327
|
+
clearTimeout(timer)
|
|
1328
|
+
try { conn.destroy() } catch { /* */ }
|
|
1329
|
+
resolve(result.healthy && codexRuntimeGeneration(dir) !== generation
|
|
1330
|
+
? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
|
|
1331
|
+
: result)
|
|
1332
|
+
}
|
|
1333
|
+
const fail = (error: string) => done({ healthy: false, references: [...references.values()], error })
|
|
1334
|
+
timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000)
|
|
1335
|
+
conn.on('error', (e) => fail(`codex app-server ownership probe failed: ${rpcError(e)}`))
|
|
1336
|
+
conn.on('close', () => fail('codex app-server closed during ownership probe'))
|
|
1337
|
+
const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
|
|
1338
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
1339
|
+
const handle = (json: string) => {
|
|
1340
|
+
let m: JsonRpc
|
|
1341
|
+
try { m = JSON.parse(json) } catch { return }
|
|
1342
|
+
if (m.error) {
|
|
1343
|
+
if (typeof m.id === 'number' && loadedRequests.has(m.id)) return fail(`codex app-server loaded/list failed: ${m.error.message || JSON.stringify(m.error)}`)
|
|
1344
|
+
const request = typeof m.id === 'number' ? requests.get(m.id) : undefined
|
|
1345
|
+
if (request) {
|
|
1346
|
+
requests.delete(m.id!)
|
|
1347
|
+
if (!requests.size) done({ healthy: true, references: [...references.values()] })
|
|
1348
|
+
return
|
|
1349
|
+
}
|
|
1350
|
+
return fail(`codex app-server ownership request ${m.id ?? 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}`)
|
|
1351
|
+
}
|
|
1352
|
+
if (m.id === 1 && m.result) {
|
|
1353
|
+
send({ method: 'initialized', params: {} })
|
|
1354
|
+
loadedRequests.add(loadedRequestId)
|
|
1355
|
+
return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { limit: 100 } })
|
|
1356
|
+
}
|
|
1357
|
+
if (typeof m.id === 'number' && loadedRequests.has(m.id) && m.result) {
|
|
1358
|
+
loadedRequests.delete(m.id)
|
|
1359
|
+
const data = (m.result as { data?: unknown }).data
|
|
1360
|
+
const ids = [...new Set(Array.isArray(data) ? data.flatMap((item) => {
|
|
1361
|
+
if (typeof item === 'string') return [item]
|
|
1362
|
+
const id = (item as { id?: unknown; threadId?: unknown })?.id ?? (item as { threadId?: unknown })?.threadId
|
|
1363
|
+
return typeof id === 'string' ? [id] : []
|
|
1364
|
+
}) : [])]
|
|
1365
|
+
for (const threadId of ids) loadedIds.add(threadId)
|
|
1366
|
+
const next = (m.result as { nextCursor?: unknown }).nextCursor
|
|
1367
|
+
loadedCursor = typeof next === 'string' && next ? next : null
|
|
1368
|
+
if (loadedCursor) {
|
|
1369
|
+
loadedRequestId++
|
|
1370
|
+
loadedRequests.add(loadedRequestId)
|
|
1371
|
+
return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { cursor: loadedCursor, limit: 100 } })
|
|
1372
|
+
}
|
|
1373
|
+
// Continue with the complete paginated set, not just the first manager page.
|
|
1374
|
+
if (!loadedIds.size) return done({ healthy: true, references: [] })
|
|
1375
|
+
loadedIds.forEach((threadId) => {
|
|
1376
|
+
const id = 100 + requests.size
|
|
1377
|
+
references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' })
|
|
1378
|
+
requests.set(id, threadId)
|
|
1379
|
+
send({ id, method: 'thread/read', params: { threadId, includeTurns: true } })
|
|
1380
|
+
})
|
|
1381
|
+
return
|
|
1382
|
+
}
|
|
1383
|
+
if (typeof m.id === 'number' && requests.has(m.id) && m.result) {
|
|
1384
|
+
const threadId = requests.get(m.id)!
|
|
1385
|
+
requests.delete(m.id)
|
|
1386
|
+
const thread = (m.result as { thread?: { turns?: Array<{ id?: string; status?: string }> } }).thread
|
|
1387
|
+
const turnId = activeTurnIdFromThread(m.result)
|
|
1388
|
+
references.set(threadId, {
|
|
1389
|
+
referenceId: threadId,
|
|
1390
|
+
turnPresence: !Array.isArray(thread?.turns) ? 'unknown' : turnId ? 'active' : 'idle',
|
|
1391
|
+
...(turnId ? { turnId } : {}),
|
|
1392
|
+
})
|
|
1393
|
+
if (!requests.size) done({ healthy: true, references: [...references.values()] })
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
conn.on('data', (chunk: Buffer) => {
|
|
1397
|
+
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
1398
|
+
if (!upgraded) {
|
|
1399
|
+
const i = fs.buf.indexOf('\r\n\r\n')
|
|
1400
|
+
if (i < 0) return
|
|
1401
|
+
const head = fs.buf.slice(0, i).toString('utf8')
|
|
1402
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return fail(`codex app-server refused ownership probe: ${head.split('\r\n')[0]}`)
|
|
1403
|
+
upgraded = true
|
|
1404
|
+
fs.buf = fs.buf.slice(i + 4)
|
|
1405
|
+
send(wsInitialize)
|
|
1406
|
+
}
|
|
1407
|
+
if (drainWsFrames(fs, conn, handle)) fail('codex app-server closed during ownership probe')
|
|
1408
|
+
})
|
|
1409
|
+
})
|
|
1410
|
+
})()
|
|
1411
|
+
}
|
|
1412
|
+
|
|
657
1413
|
// @@@ codexStartThread - the BACKEND owns the thread. On the shared PER-PROJECT app-server we `thread/start
|
|
658
1414
|
// { cwd }` (codex resolves config/hooks/AGENTS.md from that worktree cwd — exactly as claude loads CLAUDE.md
|
|
659
1415
|
// per-worktree — so one project-scoped server behaves analogously to a per-worktree launch), and the result
|
|
660
1416
|
// carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
|
|
661
1417
|
// fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
|
|
662
1418
|
// Never throws.
|
|
663
|
-
|
|
1419
|
+
// @@@ codexStartThreadParams - what a BACKEND-owned thread is created with. `config` is the per-request
|
|
1420
|
+
// override map (the only channel that reaches a thread): `bypass_hook_trust` so our hooks run, and
|
|
1421
|
+
// `shell_environment_policy.set` so every command this thread spawns carries the governed record id. The
|
|
1422
|
+
// latter is codex's answer to a structural fact — a codex tool shell descends from the SHARED app-server, so
|
|
1423
|
+
// it must inherit no identity and be given its own instead (verified live: the shell reports exactly the
|
|
1424
|
+
// injected id, and the launcher's env leaks nothing).
|
|
1425
|
+
export function codexStartThreadParams(cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Record<string, unknown> {
|
|
1426
|
+
const config = {
|
|
1427
|
+
...(bypassHookTrust ? { bypass_hook_trust: true } : {}),
|
|
1428
|
+
...(shellEnv && Object.keys(shellEnv).length ? { shell_environment_policy: { set: shellEnv } } : {}),
|
|
1429
|
+
}
|
|
1430
|
+
return { ...(cwd ? { cwd } : {}), ...(Object.keys(config).length ? { config } : {}) }
|
|
1431
|
+
}
|
|
1432
|
+
export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
|
|
664
1433
|
return new Promise((resolve) => {
|
|
665
1434
|
const conn: Socket = createConnection(sock)
|
|
666
1435
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
@@ -690,8 +1459,13 @@ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = f
|
|
|
690
1459
|
// own `--remote resume` TUI client injects it. Without it the worktree's UNtrusted `.codex` config layer
|
|
691
1460
|
// stays disabled → no local hooks discovered → no Stop gate. Only on the bypass path (older codex without
|
|
692
1461
|
// the flag uses writeCodexTrust's hash and never sees this key).
|
|
693
|
-
|
|
694
|
-
|
|
1462
|
+
// The same override map carries the thread's IDENTITY. A codex tool shell is spawned by the SHARED
|
|
1463
|
+
// app-server, so it can inherit no session id — and must not, that leak was github#76. Codex's own
|
|
1464
|
+
// `shell_environment_policy.set` injects vars into every command THIS thread spawns, so the backend
|
|
1465
|
+
// stamps the governed record id there at thread creation, the same moment and the same knowledge with
|
|
1466
|
+
// which a claude launch bakes it into its agent's env. Identity then arrives per-thread, needing no
|
|
1467
|
+
// alias, no store lookup, and no cwd anywhere downstream.
|
|
1468
|
+
return send({ id: 2, method: 'thread/start', params: codexStartThreadParams(cwd, bypassHookTrust, shellEnv) })
|
|
695
1469
|
}
|
|
696
1470
|
if (m.id === 2 && m.result) {
|
|
697
1471
|
const tid = (m.result as { thread?: { id?: string } })?.thread?.id
|
|
@@ -1129,10 +1903,57 @@ export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
|
|
|
1129
1903
|
return `bash -lc ${shQuote(script)} spexcode-opencode`
|
|
1130
1904
|
}
|
|
1131
1905
|
|
|
1906
|
+
const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) =>
|
|
1907
|
+
(tmuxAlive && !!socketLive ? 'online' : 'offline')
|
|
1908
|
+
|
|
1909
|
+
const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
|
|
1910
|
+
(tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
|
|
1911
|
+
|
|
1912
|
+
const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
|
|
1913
|
+
|
|
1914
|
+
// @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
|
|
1915
|
+
// socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
|
|
1916
|
+
// faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
|
|
1917
|
+
// per-session resource NOT scoped by the store (`SPEXCODE_HOME`) or the tmux server (`SPEXCODE_TMUX`), so an
|
|
1918
|
+
// isolated instance closing an id that also names a LIVE session elsewhere had its `kill-session` miss (that
|
|
1919
|
+
// IS namespaced) while this unlink landed (it is not) — deleting a working agent's socket out from under it.
|
|
1920
|
+
// The damage is invisible and permanent: the listener stays bound to an unlinked path, so nothing can ever
|
|
1921
|
+
// connect again (delivery fails its existsSync gate) and every probe ENOENTs, which the liveness axis reads as
|
|
1922
|
+
// PROVEN death — a live worker reading `offline`, which in turn disarms the relaunch guard.
|
|
1923
|
+
// So: poll until death is proven, then unlink. A listener still answering past the wall is somebody's live
|
|
1924
|
+
// agent — mine that failed to die, or one that was never mine — and either way it is not ours to remove: leave
|
|
1925
|
+
// it and say so. `unproven` is not proof either, so it is left too. The asymmetry is deliberate: a dead-but-
|
|
1926
|
+
// unlinked file is harmless residue the next teardown reaps, while a wrong unlink strands a working agent.
|
|
1927
|
+
const SOCK_DEATH_WALL_MS = 2000 // a killed agent releases its listener in well under this; the wall only bounds the wrong case
|
|
1928
|
+
const SOCK_DEATH_POLL_MS = 100
|
|
1929
|
+
export const unlinkSocks = async (...paths: string[]): Promise<void> => {
|
|
1930
|
+
for (const path of paths) {
|
|
1931
|
+
if (!existsSync(path)) continue
|
|
1932
|
+
const deadline = Date.now() + SOCK_DEATH_WALL_MS
|
|
1933
|
+
let probe = await listenerAt(path)
|
|
1934
|
+
while (probe !== 'dead' && Date.now() < deadline) {
|
|
1935
|
+
await new Promise((r) => setTimeout(r, SOCK_DEATH_POLL_MS))
|
|
1936
|
+
probe = await listenerAt(path)
|
|
1937
|
+
}
|
|
1938
|
+
if (probe !== 'dead') {
|
|
1939
|
+
console.warn(`spex: left ${path} in place — ${probe === 'live'
|
|
1940
|
+
? 'a listener is still answering it, so it belongs to a running agent (this teardown did not kill it, or it was never ours)'
|
|
1941
|
+
: 'the listener probe could not conclude, and death was never proven'}`)
|
|
1942
|
+
continue
|
|
1943
|
+
}
|
|
1944
|
+
try { rmSync(path, { force: true }) } catch { /* already gone */ }
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
|
|
1948
|
+
const rendezvousLaunchEnv = (id: string): string[] => [
|
|
1949
|
+
'CLAUDE_BG_BACKEND=daemon',
|
|
1950
|
+
`CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`,
|
|
1951
|
+
]
|
|
1952
|
+
const noLaunchEnv = (): string[] => []
|
|
1953
|
+
|
|
1132
1954
|
export const claudeHarness: Harness = {
|
|
1133
1955
|
id: 'claude',
|
|
1134
1956
|
headless: false,
|
|
1135
|
-
messageStream: false,
|
|
1136
1957
|
events: CLAUDE_EVENTS,
|
|
1137
1958
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
1138
1959
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
@@ -1140,6 +1961,7 @@ export const claudeHarness: Harness = {
|
|
|
1140
1961
|
baseCmd: claudeBaseCmd,
|
|
1141
1962
|
sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
|
|
1142
1963
|
sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
|
|
1964
|
+
launchEnv: rendezvousLaunchEnv,
|
|
1143
1965
|
shimFile: (proj) => join(proj, '.claude', 'settings.json'),
|
|
1144
1966
|
worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
|
|
1145
1967
|
contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
|
|
@@ -1153,9 +1975,11 @@ export const claudeHarness: Harness = {
|
|
|
1153
1975
|
// online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
|
|
1154
1976
|
// the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
|
|
1155
1977
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
1156
|
-
liveness:
|
|
1978
|
+
liveness: socketListenerLiveness,
|
|
1979
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
1157
1980
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1158
|
-
cleanupRuntime: (rec) =>
|
|
1981
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
1982
|
+
coldRuntime: async () => ({ ok: true }),
|
|
1159
1983
|
// the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
|
|
1160
1984
|
// and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
|
|
1161
1985
|
// the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
|
|
@@ -1166,6 +1990,11 @@ export const claudeHarness: Harness = {
|
|
|
1166
1990
|
? 'the claude TUI is focused on its sessions panel ("← for agents"), which silently swallows injected prompts — press Enter in the session terminal to return to the composer, then resend'
|
|
1167
1991
|
: null,
|
|
1168
1992
|
resumeArg: (rec) => `--resume ${rec.session}`,
|
|
1993
|
+
// claude's settled launch failures, in its own words: a `--resume` id it has no conversation for (the id was
|
|
1994
|
+
// never claude's, or its transcript is gone), and a rejected credential. Both are the same command failing
|
|
1995
|
+
// the same way every time — the human must repair the conversation or the login, so the transport stops at
|
|
1996
|
+
// one attempt and shows this line instead of burying it under two more identical failures.
|
|
1997
|
+
fatalLaunchOutput: ['No conversation found with session ID', 'Invalid API key', 'Please run /login'],
|
|
1169
1998
|
}
|
|
1170
1999
|
|
|
1171
2000
|
// Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
|
|
@@ -1174,23 +2003,25 @@ export const claudeHeadlessHarness: Harness = {
|
|
|
1174
2003
|
...claudeHarness,
|
|
1175
2004
|
id: 'claude-headless',
|
|
1176
2005
|
headless: true,
|
|
1177
|
-
|
|
2006
|
+
runtimeOwnership: 'adapter',
|
|
1178
2007
|
ownsRendezvous: false,
|
|
1179
2008
|
paneTitleIsSelfSummary: false,
|
|
1180
2009
|
launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
|
|
1181
|
-
|
|
1182
|
-
//
|
|
1183
|
-
|
|
2010
|
+
launchEnv: noLaunchEnv,
|
|
2011
|
+
// Liveness is the intact, non-stopped record's property. A missing controller/child fails loudly at control
|
|
2012
|
+
// time rather than turning an idle (no child) session into a speculative offline row.
|
|
2013
|
+
liveness: recordOnline,
|
|
1184
2014
|
deliver: deliverViaClaudeHeadless,
|
|
1185
2015
|
interrupt: interruptClaudeHeadless,
|
|
1186
|
-
cleanupRuntime: (rec) =>
|
|
2016
|
+
cleanupRuntime: (rec) => unlinkSocks(claudeHeadlessSock(rec.session)),
|
|
2017
|
+
coldRuntime: async () => ({ ok: false, reason: 'claude-headless has no exact resident unload verification' }),
|
|
1187
2018
|
deliveryBlockedBy: undefined,
|
|
1188
2019
|
}
|
|
1189
2020
|
|
|
1190
2021
|
export const codexHarness: Harness = {
|
|
1191
2022
|
id: 'codex',
|
|
1192
2023
|
headless: false,
|
|
1193
|
-
|
|
2024
|
+
sharedRuntimeSpawn: true,
|
|
1194
2025
|
events: CODEX_EVENTS,
|
|
1195
2026
|
ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
|
|
1196
2027
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
@@ -1198,6 +2029,7 @@ export const codexHarness: Harness = {
|
|
|
1198
2029
|
baseCmd: codexBaseCmd,
|
|
1199
2030
|
sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
|
|
1200
2031
|
sessionEnvVar: 'CODEX_THREAD_ID',
|
|
2032
|
+
launchEnv: noLaunchEnv,
|
|
1201
2033
|
// Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
|
|
1202
2034
|
// (codex-rs `root_checkout_hooks_folder_for_dir` rewrites the hooks-config folder to <repo_root>/<rel>/.codex
|
|
1203
2035
|
// for any linked worktree). Every worktree's thread (cwd = worktree root) therefore reads the SAME
|
|
@@ -1252,12 +2084,228 @@ export const codexHarness: Harness = {
|
|
|
1252
2084
|
if (pane?.pidAlive !== undefined) return pane.pidAlive ? 'online' : 'offline'
|
|
1253
2085
|
return paneTreeRunsCodex(pane) ? 'online' : 'offline'
|
|
1254
2086
|
},
|
|
2087
|
+
leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
|
|
1255
2088
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
1256
|
-
cleanupRuntime: () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
2089
|
+
cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
2090
|
+
coldRetirementPreflight: async (rec) => {
|
|
2091
|
+
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2092
|
+
const threadId = rec.harnessSessionId
|
|
2093
|
+
const dir = runtimeRoot()
|
|
2094
|
+
const generationBefore = codexRuntimeGeneration(dir)
|
|
2095
|
+
if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
2096
|
+
const result = await codexColdPreflight(threadId, dir, generationBefore)
|
|
2097
|
+
if (codexRuntimeGeneration(dir) !== generationBefore)
|
|
2098
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
|
|
2099
|
+
if (!result.ok) return result
|
|
2100
|
+
if (!result.alreadyCold)
|
|
2101
|
+
return { ok: false, reason: `Codex target subtree ${result.receipt.activeIds.join(', ')} is not fully archived` }
|
|
2102
|
+
return { ok: true, alreadyCold: true }
|
|
2103
|
+
},
|
|
2104
|
+
coldPreflight: async (rec) => {
|
|
2105
|
+
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2106
|
+
return codexColdPreflight(rec.harnessSessionId)
|
|
2107
|
+
},
|
|
2108
|
+
coldRuntime: async (rec, suppliedReceipt) => {
|
|
2109
|
+
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2110
|
+
const threadId = rec.harnessSessionId
|
|
2111
|
+
const dir = runtimeRoot()
|
|
2112
|
+
const sock = codexAppServerSock(dir)
|
|
2113
|
+
const generationBefore = codexRuntimeGeneration(dir)
|
|
2114
|
+
if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
2115
|
+
if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
|
|
2116
|
+
return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' }
|
|
2117
|
+
const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null
|
|
2118
|
+
if (frozenPlan && frozenPlan.generation !== generationBefore)
|
|
2119
|
+
return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' }
|
|
2120
|
+
const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore)
|
|
2121
|
+
if (!preflight.ok) return preflight
|
|
2122
|
+
const plan = frozenPlan ?? preflight.receipt
|
|
2123
|
+
if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
|
|
2124
|
+
!sameParentEdges(frozenPlan.parentEdges, preflight.receipt.parentEdges) ||
|
|
2125
|
+
!sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
|
|
2126
|
+
!sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
|
|
2127
|
+
return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' }
|
|
2128
|
+
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
2129
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' }
|
|
2130
|
+
if (plan.activeIds.length === 0) return { ok: true }
|
|
2131
|
+
const subtreeSet = new Set(plan.subtreeIds)
|
|
2132
|
+
const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId))
|
|
2133
|
+
const fence = { dir, generation: plan.generation }
|
|
2134
|
+
|
|
2135
|
+
const compensate = async (reason: string): Promise<{ ok: false; reason: string }> => {
|
|
2136
|
+
const restored = await codexRestoreColdPlan(plan, dir)
|
|
2137
|
+
return { ok: false, reason: restored.ok ? reason : `${reason}; ${restored.reason}` }
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
|
|
2141
|
+
const after = await codexColdPreflight(threadId, dir, plan.generation)
|
|
2142
|
+
if (!after.ok) return after
|
|
2143
|
+
if (codexRuntimeGeneration(dir) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
|
|
2144
|
+
if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
|
|
2145
|
+
return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` }
|
|
2146
|
+
if (after.receipt.activeIds.length)
|
|
2147
|
+
return { ok: false, reason: `Codex target subtree remains in the active collection (${after.receipt.activeIds.join(', ')})` }
|
|
2148
|
+
if (!sameIdSet(plan.subtreeIds, after.receipt.archivedIds))
|
|
2149
|
+
return { ok: false, reason: 'Codex target subtree is not uniquely archived after cold teardown' }
|
|
2150
|
+
const afterIds = new Set(after.receipt.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId)))
|
|
2151
|
+
if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
|
|
2152
|
+
return { ok: true }
|
|
2153
|
+
}
|
|
2154
|
+
for (const id of plan.activeIds) {
|
|
2155
|
+
const archived = await codexThreadMutation(sock, 'thread/archive', id, fence)
|
|
2156
|
+
if (!archived.ok) return compensate(`${archived.error} while archiving Codex subtree member ${id}`)
|
|
2157
|
+
}
|
|
2158
|
+
let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
|
|
2159
|
+
const verifyDeadline = Date.now() + 30_000
|
|
2160
|
+
for (let attempt = 0; attempt < 6 && Date.now() < verifyDeadline; attempt++) {
|
|
2161
|
+
verified = await coldCheck()
|
|
2162
|
+
if (verified.ok) break
|
|
2163
|
+
if (Date.now() < verifyDeadline) await new Promise((resolve) => setTimeout(resolve, 100))
|
|
2164
|
+
}
|
|
2165
|
+
if (verified.ok) return verified
|
|
2166
|
+
return compensate(verified.reason)
|
|
2167
|
+
},
|
|
2168
|
+
restoreRuntime: async (rec, suppliedReceipt) => {
|
|
2169
|
+
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2170
|
+
if (suppliedReceipt !== undefined) {
|
|
2171
|
+
if (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== rec.harnessSessionId)
|
|
2172
|
+
return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' }
|
|
2173
|
+
return codexRestoreColdPlan(suppliedReceipt)
|
|
2174
|
+
}
|
|
2175
|
+
const sock = codexAppServerSock(runtimeRoot())
|
|
2176
|
+
const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
|
|
2177
|
+
const [active, archived] = await Promise.all([
|
|
2178
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
2179
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
2180
|
+
])
|
|
2181
|
+
if (!active.ok || !archived.ok) return { ok: false, reason: 'Codex restore state could not be reconciled' }
|
|
2182
|
+
const inActive = active.ids.includes(rec.harnessSessionId!)
|
|
2183
|
+
const inArchived = archived.ids.includes(rec.harnessSessionId!)
|
|
2184
|
+
if (inActive && !inArchived) return { ok: true }
|
|
2185
|
+
if (inArchived && !inActive) return { ok: false, reason: 'Codex thread remains archived; restore can be retried' }
|
|
2186
|
+
return { ok: false, reason: 'Codex restore state is ambiguous (thread in both or neither collection)' }
|
|
2187
|
+
}
|
|
2188
|
+
const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId)
|
|
2189
|
+
if (!restored.ok) return reconcile()
|
|
2190
|
+
return reconcile()
|
|
2191
|
+
},
|
|
2192
|
+
sharedRuntimes: (runtimeDir) => [{
|
|
2193
|
+
key: 'codex-app-server',
|
|
2194
|
+
label: 'Codex app-server',
|
|
2195
|
+
pidFile: codexAppServerPid(runtimeDir),
|
|
2196
|
+
receiptFile: codexAppServerReceipt(runtimeDir),
|
|
2197
|
+
residency: async () => {
|
|
2198
|
+
const sock = codexAppServerSock(runtimeDir)
|
|
2199
|
+
let pid = 0
|
|
2200
|
+
try { pid = Number(readFileSync(codexAppServerPid(runtimeDir), 'utf8').trim()) } catch { /* stale/missing pid */ }
|
|
2201
|
+
const pidLive = pid > 0 && !!processStartToken(pid)
|
|
2202
|
+
const listener = await listenerAt(sock, 800)
|
|
2203
|
+
if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
|
|
2204
|
+
if (pidLive && (!codexRuntimeGeneration(runtimeDir) || listener !== 'live'))
|
|
2205
|
+
return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
|
|
2206
|
+
if (!pidLive || listener !== 'live')
|
|
2207
|
+
return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
|
|
2208
|
+
const result = await codexLoadedReferenceIds(sock)
|
|
2209
|
+
return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
|
|
2210
|
+
},
|
|
2211
|
+
mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts),
|
|
2212
|
+
probe: () => codexSharedRuntimeProbe(runtimeDir),
|
|
2213
|
+
}],
|
|
1257
2214
|
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
1258
2215
|
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
1259
2216
|
// the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
|
|
1260
2217
|
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
2218
|
+
// codex's own settled failure: a thread id whose rollout is not on disk can never be resumed, so the launch
|
|
2219
|
+
// that says so has already decided. (Its transient sibling — the rollout still being written — is handled
|
|
2220
|
+
// BEFORE launch by waitForCodexRollout, so what reaches here is the permanent case.)
|
|
2221
|
+
fatalLaunchOutput: ['no rollout found for thread id'],
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
type CodexHeadlessLaunchReadinessProof = Readonly<{
|
|
2225
|
+
kind: 'codex-headless-shared-runtime'
|
|
2226
|
+
descriptorKey: string
|
|
2227
|
+
generation: CodexRuntimeGenerationProof
|
|
2228
|
+
target: Readonly<{
|
|
2229
|
+
sessionId: string
|
|
2230
|
+
threadId: string
|
|
2231
|
+
ownerSessionId: string
|
|
2232
|
+
ownerCount: 1
|
|
2233
|
+
ownerState: 'governed'
|
|
2234
|
+
referenceState: 'loaded'
|
|
2235
|
+
protectsControlPlane: true
|
|
2236
|
+
}>
|
|
2237
|
+
}>
|
|
2238
|
+
|
|
2239
|
+
const sameCodexHeadlessReadinessProof = (left: CodexHeadlessLaunchReadinessProof, right: CodexHeadlessLaunchReadinessProof) =>
|
|
2240
|
+
left.kind === right.kind &&
|
|
2241
|
+
left.descriptorKey === right.descriptorKey &&
|
|
2242
|
+
codexRuntimeGenerationToken(left.generation) === codexRuntimeGenerationToken(right.generation) &&
|
|
2243
|
+
left.target.sessionId === right.target.sessionId &&
|
|
2244
|
+
left.target.threadId === right.target.threadId &&
|
|
2245
|
+
left.target.ownerSessionId === right.target.ownerSessionId &&
|
|
2246
|
+
left.target.ownerCount === right.target.ownerCount &&
|
|
2247
|
+
left.target.ownerState === right.target.ownerState &&
|
|
2248
|
+
left.target.referenceState === right.target.referenceState &&
|
|
2249
|
+
left.target.protectsControlPlane === right.target.protectsControlPlane
|
|
2250
|
+
|
|
2251
|
+
const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string): string[] | null => {
|
|
2252
|
+
const root = join(runtimeDir, 'sessions')
|
|
2253
|
+
let entries
|
|
2254
|
+
try { entries = readdirSync(root, { withFileTypes: true }) }
|
|
2255
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : null }
|
|
2256
|
+
const owners: string[] = []
|
|
2257
|
+
for (const entry of entries) {
|
|
2258
|
+
if (!entry.isDirectory()) continue
|
|
2259
|
+
let parsed: unknown
|
|
2260
|
+
try { parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8')) }
|
|
2261
|
+
catch (error) {
|
|
2262
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
|
2263
|
+
return null
|
|
2264
|
+
}
|
|
2265
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
2266
|
+
const record = parsed as { session_id?: unknown; governed?: unknown; harness?: unknown; harness_session_id?: unknown }
|
|
2267
|
+
if (typeof record.session_id !== 'string') return null
|
|
2268
|
+
if (record.governed !== true) continue
|
|
2269
|
+
const harnessId = typeof record.harness === 'string' && record.harness ? record.harness : defaultHarness.id
|
|
2270
|
+
let sharesDescriptor = false
|
|
2271
|
+
try { sharesDescriptor = (harnessById(harnessId).sharedRuntimes?.(runtimeDir) ?? []).some((descriptor) => descriptor.key === descriptorKey) }
|
|
2272
|
+
catch { return null }
|
|
2273
|
+
if (sharesDescriptor && record.harness_session_id === threadId) owners.push(record.session_id)
|
|
2274
|
+
}
|
|
2275
|
+
return owners
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyRecord | null): Promise<CodexHeadlessLaunchReadinessProof | null> {
|
|
2279
|
+
const record = current()
|
|
2280
|
+
if (!record?.governed || record.stopped || record.archived || !record.harnessSessionId) return null
|
|
2281
|
+
const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
|
|
2282
|
+
.find((candidate) => candidate.key === 'codex-app-server')
|
|
2283
|
+
if (!descriptor?.residency) return null
|
|
2284
|
+
const generationBefore = codexRuntimeGenerationProof(record.runtimeDir)
|
|
2285
|
+
if (!generationBefore) return null
|
|
2286
|
+
let resident: Awaited<ReturnType<NonNullable<SharedRuntimeDescriptor['residency']>>>
|
|
2287
|
+
try { resident = await descriptor.residency() }
|
|
2288
|
+
catch { return null }
|
|
2289
|
+
if (!resident.healthy) return null
|
|
2290
|
+
if (!resident.referenceIds.includes(record.harnessSessionId)) return null
|
|
2291
|
+
const owners = governedSharedRuntimeOwners(record.runtimeDir, descriptor.key, record.harnessSessionId)
|
|
2292
|
+
if (!owners || owners.length !== 1 || owners[0] !== record.session) return null
|
|
2293
|
+
const generationAfter = codexRuntimeGenerationProof(record.runtimeDir)
|
|
2294
|
+
if (!generationAfter || codexRuntimeGenerationToken(generationBefore) !== codexRuntimeGenerationToken(generationAfter)) return null
|
|
2295
|
+
return Object.freeze({
|
|
2296
|
+
kind: 'codex-headless-shared-runtime',
|
|
2297
|
+
descriptorKey: descriptor.key,
|
|
2298
|
+
generation: generationAfter,
|
|
2299
|
+
target: Object.freeze({
|
|
2300
|
+
sessionId: record.session,
|
|
2301
|
+
threadId: record.harnessSessionId,
|
|
2302
|
+
ownerSessionId: owners[0],
|
|
2303
|
+
ownerCount: 1,
|
|
2304
|
+
ownerState: 'governed',
|
|
2305
|
+
referenceState: 'loaded',
|
|
2306
|
+
protectsControlPlane: true,
|
|
2307
|
+
}),
|
|
2308
|
+
})
|
|
1261
2309
|
}
|
|
1262
2310
|
|
|
1263
2311
|
// Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
|
|
@@ -1267,12 +2315,27 @@ export const codexHeadlessHarness: Harness = {
|
|
|
1267
2315
|
...codexHarness,
|
|
1268
2316
|
id: 'codex-headless',
|
|
1269
2317
|
headless: true,
|
|
2318
|
+
runtimeOwnership: 'adapter',
|
|
1270
2319
|
launchOneShot: true,
|
|
1271
|
-
messageStream: false,
|
|
1272
2320
|
launchCmd: (id, runtimeDir, cmd) => codexHeadlessLaunchCommand(id, codexBaseCmd(cmd), undefined, runtimeDir ?? runtimeRoot()),
|
|
1273
|
-
// Record-backed liveness is the family contract for sleeping headless threads.
|
|
1274
|
-
//
|
|
1275
|
-
liveness:
|
|
2321
|
+
// Record-backed liveness is the family contract for sleeping headless threads. An explicit stop is the one
|
|
2322
|
+
// offline marker; other app-server/thread failures surface through delivery rather than speculative liveness.
|
|
2323
|
+
liveness: recordOnline,
|
|
2324
|
+
launchReady: async (current, deadline) => {
|
|
2325
|
+
for (;;) {
|
|
2326
|
+
const proof = await codexHeadlessReadinessProof(current)
|
|
2327
|
+
if (proof) return {
|
|
2328
|
+
proof,
|
|
2329
|
+
validate: async (latest) => {
|
|
2330
|
+
const currentProof = await codexHeadlessReadinessProof(latest)
|
|
2331
|
+
return !!currentProof && sameCodexHeadlessReadinessProof(proof, currentProof)
|
|
2332
|
+
},
|
|
2333
|
+
}
|
|
2334
|
+
const remaining = deadline - Date.now()
|
|
2335
|
+
if (remaining <= 0) return null
|
|
2336
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(200, remaining)))
|
|
2337
|
+
}
|
|
2338
|
+
},
|
|
1276
2339
|
// There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
|
|
1277
2340
|
// runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
|
|
1278
2341
|
resumeArg: () => '',
|
|
@@ -1283,8 +2346,8 @@ export const codexHeadlessHarness: Harness = {
|
|
|
1283
2346
|
// lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
|
|
1284
2347
|
// hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
|
|
1285
2348
|
// extension (.pi/extensions/spexcode.ts, run natively by pi) that forwards five claude-shaped events to
|
|
1286
|
-
// dispatch.sh AND binds this session's rendezvous socket itself (
|
|
1287
|
-
// CLAUDE_BG_RENDEZVOUS_SOCK
|
|
2349
|
+
// dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
|
|
2350
|
+
// CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
|
|
1288
2351
|
// deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
|
|
1289
2352
|
// extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
|
|
1290
2353
|
// checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
|
|
@@ -1292,7 +2355,6 @@ export const codexHeadlessHarness: Harness = {
|
|
|
1292
2355
|
export const piHarness: Harness = {
|
|
1293
2356
|
id: 'pi',
|
|
1294
2357
|
headless: false,
|
|
1295
|
-
messageStream: false,
|
|
1296
2358
|
events: PI_EVENTS,
|
|
1297
2359
|
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
1298
2360
|
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
@@ -1300,6 +2362,7 @@ export const piHarness: Harness = {
|
|
|
1300
2362
|
baseCmd: piBaseCmd,
|
|
1301
2363
|
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
1302
2364
|
sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
|
|
2365
|
+
launchEnv: rendezvousLaunchEnv,
|
|
1303
2366
|
shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
|
|
1304
2367
|
worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
|
|
1305
2368
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
|
|
@@ -1315,9 +2378,11 @@ export const piHarness: Harness = {
|
|
|
1315
2378
|
slashCommands: piSlashCommands,
|
|
1316
2379
|
// claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
|
|
1317
2380
|
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
1318
|
-
liveness:
|
|
2381
|
+
liveness: socketListenerLiveness,
|
|
2382
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
1319
2383
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1320
|
-
cleanupRuntime: (rec) =>
|
|
2384
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2385
|
+
coldRuntime: async () => ({ ok: true }),
|
|
1321
2386
|
// reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
|
|
1322
2387
|
// LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
|
|
1323
2388
|
resumeArg: (rec) => `--session ${rec.session}`,
|
|
@@ -1331,15 +2396,13 @@ export const piHeadlessHarness: Harness = {
|
|
|
1331
2396
|
...piHarness,
|
|
1332
2397
|
id: 'pi-headless',
|
|
1333
2398
|
headless: true,
|
|
1334
|
-
|
|
2399
|
+
runtimeOwnership: 'adapter',
|
|
1335
2400
|
paneTitleIsSelfSummary: false,
|
|
1336
2401
|
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
1337
|
-
liveness:
|
|
2402
|
+
liveness: recordOnline,
|
|
1338
2403
|
deliver: deliverViaPiHeadless,
|
|
1339
|
-
cleanupRuntime: (rec) =>
|
|
1340
|
-
|
|
1341
|
-
try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ }
|
|
1342
|
-
},
|
|
2404
|
+
cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
|
|
2405
|
+
coldRuntime: async () => ({ ok: false, reason: 'pi-headless has no exact resident unload verification' }),
|
|
1343
2406
|
deliveryBlockedBy: undefined,
|
|
1344
2407
|
resumeArg: (rec) => `--session ${rec.session}`,
|
|
1345
2408
|
}
|
|
@@ -1347,7 +2410,6 @@ export const piHeadlessHarness: Harness = {
|
|
|
1347
2410
|
export const opencodeHarness: Harness = {
|
|
1348
2411
|
id: 'opencode',
|
|
1349
2412
|
headless: false,
|
|
1350
|
-
messageStream: false,
|
|
1351
2413
|
events: OPENCODE_EVENTS,
|
|
1352
2414
|
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
1353
2415
|
// socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
|
|
@@ -1362,6 +2424,7 @@ export const opencodeHarness: Harness = {
|
|
|
1362
2424
|
// (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
|
|
1363
2425
|
// SPEXCODE_SESSION_ID tier resolves the record.
|
|
1364
2426
|
sessionEnvVar: 'OPENCODE_SESSION_ID',
|
|
2427
|
+
launchEnv: rendezvousLaunchEnv,
|
|
1365
2428
|
// the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
|
|
1366
2429
|
// by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
|
|
1367
2430
|
shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
|
|
@@ -1379,10 +2442,11 @@ export const opencodeHarness: Harness = {
|
|
|
1379
2442
|
// online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
|
|
1380
2443
|
// (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
|
|
1381
2444
|
// load still reads honestly from the process signal instead of a false offline.
|
|
1382
|
-
liveness:
|
|
1383
|
-
|
|
2445
|
+
liveness: socketListenerOrPidAliveLiveness,
|
|
2446
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
1384
2447
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1385
|
-
cleanupRuntime: (rec) =>
|
|
2448
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2449
|
+
coldRuntime: async () => ({ ok: true }),
|
|
1386
2450
|
// owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
|
|
1387
2451
|
// SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
|
|
1388
2452
|
// which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
|
|
@@ -1396,21 +2460,19 @@ export const opencodeHeadlessHarness: Harness = {
|
|
|
1396
2460
|
...opencodeHarness,
|
|
1397
2461
|
id: 'opencode-headless',
|
|
1398
2462
|
headless: true,
|
|
1399
|
-
|
|
2463
|
+
runtimeOwnership: 'adapter',
|
|
1400
2464
|
launchCmd: (_id, _runtimeDir, cmd) => opencodeHeadlessLaunchCommand(opencodeBaseCmd(cmd)),
|
|
1401
|
-
// A sleeping native conversation is still addressable by its record. Transport breakage belongs
|
|
1402
|
-
// next delivery, where the live rendezvous or pane wake reports it loudly.
|
|
1403
|
-
liveness:
|
|
2465
|
+
// A sleeping native conversation is still addressable by its non-stopped record. Transport breakage belongs
|
|
2466
|
+
// to the next delivery, where the live rendezvous or pane wake reports it loudly.
|
|
2467
|
+
liveness: recordOnline,
|
|
2468
|
+
coldRuntime: async () => ({ ok: false, reason: 'opencode-headless has no exact resident unload verification' }),
|
|
1404
2469
|
deliver: async (rec, text) => {
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
return spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session))
|
|
2470
|
+
return deliverViaSocketOrWake(
|
|
2471
|
+
rec.session,
|
|
2472
|
+
text,
|
|
2473
|
+
() => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)),
|
|
2474
|
+
`opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
|
|
2475
|
+
)
|
|
1414
2476
|
},
|
|
1415
2477
|
}
|
|
1416
2478
|
|