spexcode 0.5.2 → 0.5.3
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 +13 -3
- package/spec-cli/src/harness.ts +946 -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 +706 -0
- package/spec-cli/src/index.ts +95 -21
- package/spec-cli/src/init.ts +47 -8
- package/spec-cli/src/issues.ts +36 -3
- package/spec-cli/src/layout.ts +193 -37
- 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 +49 -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 +99 -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 +1249 -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 +26 -6
- 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 { processStartToken, processTopology } 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,81 @@ 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
|
+
isolationFile: 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) => 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
|
+
error?: string
|
|
71
|
+
}
|
|
72
|
+
export type SharedRuntimeProbe = {
|
|
73
|
+
healthy: boolean
|
|
74
|
+
references: Array<{
|
|
75
|
+
referenceId: string
|
|
76
|
+
turnPresence: 'idle' | 'active' | 'unknown'
|
|
77
|
+
turnId?: string
|
|
78
|
+
}>
|
|
79
|
+
error?: string
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type AdapterLoadedReferenceState = {
|
|
83
|
+
healthy: boolean
|
|
84
|
+
loaded: boolean
|
|
85
|
+
error?: string
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// One project-wide resident-reference census for read projections. A shared app-server descriptor is probed
|
|
89
|
+
// once per call, then its result is joined to every record that names that adapter/thread. Product readers must
|
|
90
|
+
// not turn this into one RPC per row: a loaded thread can be externally reloaded after its cold proof was filed.
|
|
91
|
+
export async function adapterLoadedReferenceState(
|
|
92
|
+
records: readonly (HarnessLivenessRecord & { harness?: string })[],
|
|
93
|
+
runtimeDir = runtimeRoot(),
|
|
94
|
+
): Promise<Map<string, AdapterLoadedReferenceState>> {
|
|
95
|
+
const descriptors = new Map<string, SharedRuntimeDescriptor>()
|
|
96
|
+
const recordKeys = new Map<string, string[]>()
|
|
97
|
+
for (const rec of records) {
|
|
98
|
+
if (!rec.harnessSessionId) continue
|
|
99
|
+
const keys = (harnessById(rec.harness || defaultHarness.id).sharedRuntimes?.(runtimeDir) ?? []).map((descriptor) => {
|
|
100
|
+
descriptors.set(descriptor.key, descriptor)
|
|
101
|
+
return descriptor.key
|
|
102
|
+
})
|
|
103
|
+
recordKeys.set(`${rec.harness || defaultHarness.id}:${rec.harnessSessionId}`, keys)
|
|
104
|
+
}
|
|
105
|
+
const probes = await Promise.all([...descriptors.entries()].map(async ([key, descriptor]) => {
|
|
106
|
+
try {
|
|
107
|
+
const result = descriptor.residency
|
|
108
|
+
? await descriptor.residency()
|
|
109
|
+
: await descriptor.probe().then((probe) => ({ healthy: probe.healthy, referenceIds: probe.references.map((reference) => reference.referenceId), error: probe.error }))
|
|
110
|
+
return [key, result] as const
|
|
111
|
+
}
|
|
112
|
+
catch (error) { return [key, { healthy: false, referenceIds: [] as string[], error: (error as Error).message }] as const }
|
|
113
|
+
}))
|
|
114
|
+
const byKey = new Map(probes)
|
|
115
|
+
const result = new Map<string, AdapterLoadedReferenceState>()
|
|
116
|
+
for (const [recordKey, keys] of recordKeys) {
|
|
117
|
+
const refs = keys.map((key) => byKey.get(key)!).filter(Boolean)
|
|
118
|
+
if (!refs.length) continue
|
|
119
|
+
const unhealthy = refs.find((probe) => !probe.healthy)
|
|
120
|
+
const threadId = recordKey.slice(recordKey.indexOf(':') + 1)
|
|
121
|
+
result.set(recordKey, unhealthy
|
|
122
|
+
? { healthy: false, loaded: false, error: unhealthy.error || 'adapter resident-reference census is unhealthy' }
|
|
123
|
+
: { healthy: true, loaded: refs.some((probe) => probe.referenceIds.includes(threadId)) })
|
|
124
|
+
}
|
|
125
|
+
return result
|
|
126
|
+
}
|
|
45
127
|
|
|
46
128
|
export interface Harness {
|
|
47
129
|
readonly id: HarnessId
|
|
@@ -51,9 +133,18 @@ export interface Harness {
|
|
|
51
133
|
// whether the launch command intentionally exits after its first turn instead of owning a resident process.
|
|
52
134
|
// One-shot adapters must not be mistaken for a failed fast boot and retried with a duplicate prompt.
|
|
53
135
|
readonly launchOneShot?: boolean
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
136
|
+
// Adapter-owned runtime shape: headless controllers/shared threads have no interactive TUI leaf to signal.
|
|
137
|
+
readonly runtimeOwnership?: 'leaf' | 'adapter'
|
|
138
|
+
// This launch command may create its project-shared control plane through internal shared-runtime-spawn.
|
|
139
|
+
readonly sharedRuntimeSpawn?: boolean
|
|
140
|
+
// @@@ fatalLaunchOutput - extended regexes matching THIS harness's own report of a launch failure that
|
|
141
|
+
// RUNNING IT AGAIN CANNOT FIX: a conversation that does not exist, a rejected credential, a broken config.
|
|
142
|
+
// A launcher that exits within the boot window tells us only that it exited fast, which is why the transport
|
|
143
|
+
// retries — but when the harness itself named a settled cause, retrying spends a certain failure two more
|
|
144
|
+
// times and buries the one line that explains it. So the transport asks the ADAPTER, and the adapter is the
|
|
145
|
+
// only place a harness's wording is ever matched: product code consumes the verdict (retry / fatal), never
|
|
146
|
+
// the text. A harness that declares none keeps the plain bounded retry.
|
|
147
|
+
readonly fatalLaunchOutput?: readonly string[]
|
|
57
148
|
// the lifecycle events this harness fires (drives the shim + the trust hashes). Claude binds the full set;
|
|
58
149
|
// Codex's canonical hook event set (its `HookEventName` enum, codex 0.142.3) has no failed-stop and no
|
|
59
150
|
// idle/attention event, so Codex has NO equivalent of StopFailure / Notification — a real harness difference,
|
|
@@ -89,6 +180,9 @@ export interface Harness {
|
|
|
89
180
|
sessionIdArg(id: string): string
|
|
90
181
|
// the env var the agent's OWN process carries so its `spex …` calls know their session id.
|
|
91
182
|
readonly sessionEnvVar: string
|
|
183
|
+
// transport bootstrap variables scoped to this launch. Rendezvous adapters own their daemon mode + socket;
|
|
184
|
+
// product launch code only composes these with generic session/home env.
|
|
185
|
+
launchEnv(id: string): string[]
|
|
92
186
|
|
|
93
187
|
// --- materialize: shim + contract + trust ([[harness-delivery]]) ---
|
|
94
188
|
// the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
|
|
@@ -148,6 +242,17 @@ export interface Harness {
|
|
|
148
242
|
// to the shell). A missing probe (tmux/ps couldn't report) is not-live. The 'starting' boot
|
|
149
243
|
// grace lives in the caller (sessions.ts liveness), so a still-booting pane reads starting, not offline.
|
|
150
244
|
liveness(rec: HarnessLivenessRecord, tmuxAlive: boolean, runtimeDir?: string, pane?: PaneProbe, socketLive?: boolean): 'online' | 'offline'
|
|
245
|
+
// A completed launch command is only transport acceptance. An adapter with stronger runtime ownership may
|
|
246
|
+
// keep the caller waiting until the launched conversation is genuinely addressable. The lazy record source
|
|
247
|
+
// lets a one-shot launch publish its native id while readiness is pending. The returned adapter-owned fence
|
|
248
|
+
// names the facts that established readiness and revalidates those SAME facts across the caller's record
|
|
249
|
+
// commit. Null at the deadline is a launch failure; adapters without this seam retain the generic bounded
|
|
250
|
+
// liveness fence.
|
|
251
|
+
launchReady?(current: () => HarnessLaunchReadyRecord | null, deadline: number): Promise<HarnessLaunchReadinessFence | null>
|
|
252
|
+
// Exact leaf ownership evidence consumed by lifecycle teardown. The adapter returns the one argv identity
|
|
253
|
+
// token it registered for this record (session id, harness thread/generation, or null when unprovable);
|
|
254
|
+
// product lifecycle code never branches on harness names to invent this identity.
|
|
255
|
+
leafOwnerNeedle?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): string | null
|
|
151
256
|
// deliver a follow-up prompt to a LIVE session and report whether it landed. claude: through the rendezvous
|
|
152
257
|
// control socket — an ATOMIC reply+repaint chunk whose `repaint-done` proves the reply was PARSED; a close
|
|
153
258
|
// before it proves a concurrent connect kicked the chunk (the daemon is single-connection) → resend; a wall
|
|
@@ -163,7 +268,22 @@ export interface Harness {
|
|
|
163
268
|
// Remove this harness's ephemeral runtime transport after stop/close. This is the runtime inverse of
|
|
164
269
|
// launch: rendezvous owners unlink rvSock, claude-headless unlinks its control socket, Codex owns no
|
|
165
270
|
// per-session socket. Product teardown calls only this adapter method.
|
|
166
|
-
|
|
271
|
+
// Async because removal is CONDITIONAL on proof: a transport is only ours to remove once its listener is
|
|
272
|
+
// proven dead (see unlinkSocks), and that proof is a connect probe.
|
|
273
|
+
cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>
|
|
274
|
+
// Archive preflight runs BEFORE any leaf signal. It may inspect shared references to refuse an active or
|
|
275
|
+
// unknown target turn, but it must not mutate the shared runtime; coldRuntime is the sole commit primitive.
|
|
276
|
+
coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold?: boolean } | { ok: false; reason: string }>
|
|
277
|
+
// A record that is already archived needs a target-only continuing-cold proof. Unlike mutation preflight,
|
|
278
|
+
// this must not thread/read unrelated loaded siblings merely to retire a target whose runtime is absent.
|
|
279
|
+
coldRetirementPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold: true } | { ok: false; reason: string }>
|
|
280
|
+
// Optional cold-storage proof/cleanup. A harness with a per-session loaded reference must remove exactly that
|
|
281
|
+
// reference or return a loud reason; adapters without such a resident reference return {ok:true}.
|
|
282
|
+
coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
283
|
+
restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
284
|
+
// Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
|
|
285
|
+
// references and protect a sibling-owned control plane without learning harness command names.
|
|
286
|
+
sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
|
|
167
287
|
// the ONE pane state where this harness SWALLOWS a prompt that its delivery channel confirms (so no
|
|
168
288
|
// socket-side check can see it): given the live pane text, return the loud human-readable refusal (naming
|
|
169
289
|
// the recovery) or null when the pane can take a prompt. sendText captures the pane once and consults this
|
|
@@ -214,12 +334,36 @@ export type HarnessArtifacts = { skills: readonly string[]; agents: readonly str
|
|
|
214
334
|
// sessions.ts starts `claude` with CLAUDE_BG_BACKEND=daemon + CLAUDE_BG_RENDEZVOUS_SOCK=<this path> set ONLY on
|
|
215
335
|
// that one spawned command (env prefix, never global). claude opens a unix socket here; writing one line
|
|
216
336
|
// `{"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
|
-
|
|
337
|
+
// and Enters can't be corrupted the way `tmux send-keys` was. It lives in tmpdir tied to the claude process, so
|
|
338
|
+
// no extra lifecycle. liveness CONNECTS to it (a live LISTENER, not merely the file — see rendezvousListening);
|
|
339
|
+
// deliver writes to it.
|
|
340
|
+
//
|
|
341
|
+
// The path is a LAUNCH-TIME FACT, recorded — not a formula every consumer re-derives. The id alone was not
|
|
342
|
+
// enough to name it: `SPEXCODE_HOME` scopes the store and `SPEXCODE_TMUX` scopes the tmux server, so two
|
|
343
|
+
// worlds on one box (a fixture, a migration, a copied record) can hold the same session id — and while the
|
|
344
|
+
// path ignored that scoping, they SHARED this socket. That is how an isolated teardown reached out and
|
|
345
|
+
// stranded a live production agent, and delivery would have crossed the same way. So the path a launch hands
|
|
346
|
+
// its agent is derived from the runtime the session belongs to (`runtimeRoot()` — the same identity that
|
|
347
|
+
// scopes its store) and STAMPED beside the record, exactly like `agent.pid`: a launch-time fact, readable by
|
|
348
|
+
// everyone who needs to reach that agent afterwards. Recording it (rather than re-deriving) also means the
|
|
349
|
+
// derivation can change again without stranding anything already running.
|
|
350
|
+
// `legacyRvSock` is the answer for a session launched BEFORE the stamp existed — its agent really did bind
|
|
351
|
+
// the unscoped path — so those keep working untouched, and the fallback retires as they turn over.
|
|
352
|
+
export const legacyRvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
|
|
353
|
+
export const scopedRvSock = (id: string, dir = runtimeRoot()) =>
|
|
354
|
+
join(tmpdir(), `spexcode-rv-${createHash('sha1').update(dir).digest('hex').slice(0, 12)}-${id}.sock`)
|
|
355
|
+
const rvStamp = (id: string) => sessionArtifactPath(id, 'rv.path')
|
|
356
|
+
export const rvSock = (id: string): string => {
|
|
357
|
+
try { return readFileSync(rvStamp(id), 'utf8').trim() || legacyRvSock(id) } catch { return legacyRvSock(id) }
|
|
358
|
+
}
|
|
359
|
+
// launch's half: derive this session's socket in ITS runtime and record it, so every later reader (launch env,
|
|
360
|
+
// liveness probe, delivery, teardown) reads the one path the agent actually bound.
|
|
361
|
+
export function stampRvSock(id: string, dir = runtimeRoot()): string {
|
|
362
|
+
const path = scopedRvSock(id, dir)
|
|
363
|
+
mkdirSync(dirname(rvStamp(id)), { recursive: true })
|
|
364
|
+
writeFileSync(rvStamp(id), path)
|
|
365
|
+
return path
|
|
366
|
+
}
|
|
223
367
|
|
|
224
368
|
// @@@ rendezvousListening - the LISTENER check that IS claude's liveness truth ([[state]], [[harness-adapter]]).
|
|
225
369
|
// A crashed/killed claude can leave its rvSock FILE on disk (a unix-domain socket path is NOT auto-unlinked on
|
|
@@ -237,7 +381,7 @@ export const rvSock = (id: string) => join(tmpdir(), `spexcode-rv-${id}.sock`)
|
|
|
237
381
|
// wedged/thrashed path. Never throws.
|
|
238
382
|
export type ListenerProbe = 'live' | 'dead' | 'unproven'
|
|
239
383
|
const PROVEN_DEAD = new Set(['ECONNREFUSED', 'ENOENT'])
|
|
240
|
-
export function
|
|
384
|
+
export function listenerAt(path: string, timeoutMs = 800): Promise<ListenerProbe> {
|
|
241
385
|
return new Promise((resolve) => {
|
|
242
386
|
let settled = false
|
|
243
387
|
let c: ReturnType<typeof createConnection> | undefined
|
|
@@ -249,11 +393,12 @@ export function rendezvousListening(id: string, timeoutMs = 800): Promise<Listen
|
|
|
249
393
|
resolve(v)
|
|
250
394
|
}
|
|
251
395
|
const timer = setTimeout(() => done('unproven'), timeoutMs)
|
|
252
|
-
try { c = createConnection({ path
|
|
396
|
+
try { c = createConnection({ path }) } catch { return done('unproven') }
|
|
253
397
|
c.on('connect', () => done('live'))
|
|
254
398
|
c.on('error', (e) => done(PROVEN_DEAD.has((e as NodeJS.ErrnoException).code ?? '') ? 'dead' : 'unproven'))
|
|
255
399
|
})
|
|
256
400
|
}
|
|
401
|
+
export const rendezvousListening = (id: string, timeoutMs = 800): Promise<ListenerProbe> => listenerAt(rvSock(id), timeoutMs)
|
|
257
402
|
// The app-server Unix socket MUST live on a SHORT, sun_path-safe path — NOT nested under the project runtime
|
|
258
403
|
// dir. macOS caps `sun_path` at ~104 bytes, and `runtimeRoot()` flattens the ENTIRE project path into one
|
|
259
404
|
// dash-segment (`encodeProject`), so `<runtimeRoot>/codex-app-server.sock` blew past the cap on a deep macOS
|
|
@@ -277,9 +422,40 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
|
|
|
277
422
|
return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
|
|
278
423
|
}
|
|
279
424
|
export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
425
|
+
export const codexAppServerIsolation = (dir = runtimeRoot()) => join(dir, 'codex-app-server.scope')
|
|
426
|
+
type CodexRuntimeGenerationProof = Readonly<{
|
|
427
|
+
pid: number
|
|
428
|
+
startToken: string
|
|
429
|
+
processGroupId: number
|
|
430
|
+
sessionId: number
|
|
431
|
+
isolation: string
|
|
432
|
+
socket: Readonly<{ path: string; dev: number; ino: number }>
|
|
433
|
+
}>
|
|
434
|
+
function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
|
|
435
|
+
try {
|
|
436
|
+
const pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
|
|
437
|
+
const start = processStartToken(pid)
|
|
438
|
+
const scope = readFileSync(codexAppServerIsolation(dir), 'utf8').trim()
|
|
439
|
+
const topology = processTopology(pid)
|
|
440
|
+
const socketPath = codexAppServerSock(dir)
|
|
441
|
+
const socket = statSync(socketPath)
|
|
442
|
+
if (!(pid > 0) || !start || !topology || topology.startToken !== start || topology.processGroupId !== pid || topology.sessionId !== pid ||
|
|
443
|
+
scope !== `detached-v3 ${pid} ${start} ${pid} ${pid}` || !socket.isSocket()) return null
|
|
444
|
+
return Object.freeze({
|
|
445
|
+
pid,
|
|
446
|
+
startToken: start,
|
|
447
|
+
processGroupId: topology.processGroupId,
|
|
448
|
+
sessionId: topology.sessionId,
|
|
449
|
+
isolation: scope,
|
|
450
|
+
socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
|
|
451
|
+
})
|
|
452
|
+
} catch { return null }
|
|
453
|
+
}
|
|
454
|
+
const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
|
|
455
|
+
`${proof.pid}|${proof.startToken}|${proof.processGroupId}|${proof.sessionId}|${proof.isolation}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
|
|
456
|
+
function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
|
|
457
|
+
const proof = codexRuntimeGenerationProof(dir)
|
|
458
|
+
return proof ? codexRuntimeGenerationToken(proof) : null
|
|
283
459
|
}
|
|
284
460
|
|
|
285
461
|
// the spex launcher (bin/spex.mjs), baked into the codex launch script (mirrors materialize.ts's SPEX) so
|
|
@@ -369,6 +545,18 @@ export async function deliverViaRendezvous(id: string, text: string, wallMs?: nu
|
|
|
369
545
|
return { ok: false, error: `rendezvous delivery was kicked by concurrent connects ${DELIVER_ATTEMPTS}× — prompt NOT delivered, retry the send` }
|
|
370
546
|
}
|
|
371
547
|
|
|
548
|
+
export async function deliverViaSocketOrWake(
|
|
549
|
+
id: string,
|
|
550
|
+
text: string,
|
|
551
|
+
coldWake: () => Promise<DispatchResult>,
|
|
552
|
+
unprovenError: string,
|
|
553
|
+
): Promise<DispatchResult> {
|
|
554
|
+
const probe = await rendezvousListening(id)
|
|
555
|
+
if (probe === 'live') return deliverViaRendezvous(id, text)
|
|
556
|
+
if (probe === 'unproven') return { ok: false, error: unprovenError }
|
|
557
|
+
return coldWake()
|
|
558
|
+
}
|
|
559
|
+
|
|
372
560
|
type JsonRpc = { id?: number; method?: string; params?: unknown; result?: unknown; error?: { code?: number; message?: string } }
|
|
373
561
|
|
|
374
562
|
// The JSON-RPC the delivery handshake speaks, in send order. Method names + param shapes are pinned to codex
|
|
@@ -463,10 +651,17 @@ export async function reportHeadlessTurnExit(id: string, harness: string, code:
|
|
|
463
651
|
}
|
|
464
652
|
}
|
|
465
653
|
|
|
466
|
-
export function headlessTurnFailureShell(harness: string): string {
|
|
467
|
-
return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc" || true`
|
|
654
|
+
export function headlessTurnFailureShell(harness: string, swallow = true): string {
|
|
655
|
+
return `${shQuote(SPEX)} internal session-turn-fail "$SPEXCODE_SESSION_ID" ${shQuote(harness)} "$__spex_rc"${swallow ? ' || true' : ''}`
|
|
656
|
+
}
|
|
657
|
+
// @@@ sessionIdentityEnvVars - every environment variable that names ONE session: the launch-injected record
|
|
658
|
+
// id plus each adapter's own `sessionEnvVar`. Adapter-derived, so a new harness needs no edit here. A
|
|
659
|
+
// per-session process is entitled to carry them; a SHARED, project-scoped daemon must not — see the app-server
|
|
660
|
+
// spawn below.
|
|
661
|
+
export function sessionIdentityEnvVars(): string[] {
|
|
662
|
+
return [...new Set(['SPEXCODE_SESSION_ID', ...HARNESSES.map((h) => h.sessionEnvVar)])].filter(Boolean)
|
|
468
663
|
}
|
|
469
|
-
export function codexLaunchCommand(
|
|
664
|
+
export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: string, dir = runtimeRoot(), attachTui = true): string {
|
|
470
665
|
const server = process.env.SPEXCODE_CODEX_SERVER_CMD || serverCmd || codexBinary(codexCmd)
|
|
471
666
|
// The bypass flag ONLY reaches a thread's hook trust as a per-request `config` override, NOT as a CLI flag on
|
|
472
667
|
// the shared `app-server` process (the app-server never reads its own `--dangerously-bypass-hook-trust` for a
|
|
@@ -478,12 +673,14 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
478
673
|
const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
|
|
479
674
|
const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
|
|
480
675
|
const pid = codexAppServerPid(dir)
|
|
676
|
+
const isolation = codexAppServerIsolation(dir)
|
|
481
677
|
const log = join(dir, 'codex-app-server.log')
|
|
482
678
|
const lock = join(dir, 'codex-app-server.lock')
|
|
483
679
|
const script = [
|
|
484
680
|
`dir=${shQuote(dir)}`,
|
|
485
681
|
`sock=${shQuote(sock)}`,
|
|
486
682
|
`pid=${shQuote(pid)}`,
|
|
683
|
+
`isolation=${shQuote(isolation)}`,
|
|
487
684
|
`log=${shQuote(log)}`,
|
|
488
685
|
`lock=${shQuote(lock)}`,
|
|
489
686
|
// codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
|
|
@@ -519,12 +716,25 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
519
716
|
// removed, the daemon's cwd becomes a DELETED dir, and codex then fails EVERY new thread's config load with
|
|
520
717
|
// `failed to load configuration: No such file or directory` — bricking codex launch for the whole project
|
|
521
718
|
// until the daemon is killed. Running it from "$dir" (which never gets deleted) makes it deletion-proof.
|
|
522
|
-
//
|
|
523
|
-
|
|
524
|
-
'
|
|
719
|
+
// For the SAME reason it must carry no session IDENTITY: it is started by whichever session happened to
|
|
720
|
+
// launch first and then serves every later thread, whose tool shells inherit its env — so a baked
|
|
721
|
+
// SPEXCODE_SESSION_ID (or any adapter's `sessionEnvVar`) is a stale lie for every session but one, and
|
|
722
|
+
// still a lie after that session closes and its record is swept (measured: daemons here running for days
|
|
723
|
+
// under a long-gone session's id). Everything downstream that resolves identity from the env then
|
|
724
|
+
// mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
|
|
725
|
+
// inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
|
|
726
|
+
// The adapter launches its shared control plane in a new OS process group + session. `nohup` alone was not
|
|
727
|
+
// a boundary: the Codex Node launcher reset signal handling and died with the tmux pane despite a matching
|
|
728
|
+
// stamp. The internal helper uses child_process detached=true, records PID/start plus the observed pgrp/sid,
|
|
729
|
+
// and refuses unless the live process is its own session leader. The stop guard re-reads that topology.
|
|
730
|
+
` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$isolation" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
|
|
525
731
|
' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
|
|
526
732
|
'fi',
|
|
527
733
|
'rmdir "$lockd" 2>/dev/null',
|
|
734
|
+
// The delegated bearer arrived only through fd 9. The shared-spawn helper consumed it (or the already-live
|
|
735
|
+
// socket made it unnecessary); close and scrub the channel before the per-session Codex client starts.
|
|
736
|
+
'[ "${SPEXCODE_MAINTENANCE_DELEGATE_FD:-}" != "9" ] || exec 9<&-',
|
|
737
|
+
'unset SPEXCODE_MAINTENANCE_DELEGATE_FD SPEXCODE_MAINTENANCE_SESSION_ID',
|
|
528
738
|
// TWO launch modes, on ONE tail channel ("$@"). reopen() hands a `--resume <thread-id>` tail (see
|
|
529
739
|
// codexHarness.resumeArg) to bring the SAME conversation back: resume that OWNED thread DIRECTLY — no new
|
|
530
740
|
// thread, no first-turn prompt. ANY other tail is a NEW launch: BACKEND owns the thread — `codex-launch`
|
|
@@ -550,7 +760,11 @@ export function codexLaunchCommand(_id: string, codexCmd = 'codex', serverCmd?:
|
|
|
550
760
|
]),
|
|
551
761
|
`fi`,
|
|
552
762
|
`[ -n "$tid" ] || { echo "[spex] codex-launch produced no resumable thread" >&2; exit 1; }`,
|
|
553
|
-
|
|
763
|
+
// The visible TUI is the OTHER entry point that creates an execution context for this session (a fresh
|
|
764
|
+
// launch attaches to the thread codex-launch just made; a reopen resumes an existing one), so it injects
|
|
765
|
+
// the same per-thread identity through codex's own `-c` override. Same rule, both entry points: whoever
|
|
766
|
+
// creates a context stamps that context's record id, and nothing downstream re-derives it.
|
|
767
|
+
...(attachTui ? [`exec ${codexCmd}${tuiBypass} -c ${shQuote(`shell_environment_policy.set.SPEXCODE_SESSION_ID=${id}`)} --remote unix://"$sock" resume "$tid"`] : []),
|
|
554
768
|
].join('\n')
|
|
555
769
|
return `bash -lc ${shQuote(script)} spexcode-codex`
|
|
556
770
|
}
|
|
@@ -606,6 +820,239 @@ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => vo
|
|
|
606
820
|
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
821
|
const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
|
|
608
822
|
|
|
823
|
+
// Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
824
|
+
// defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
|
|
825
|
+
type CodexGenerationFence = { dir: string; generation: string }
|
|
826
|
+
function codexThreadMutation(sock: string, method: 'thread/archive' | 'thread/unarchive', threadId: string, fence?: CodexGenerationFence): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
827
|
+
const generationError = () => fence && codexRuntimeGeneration(fence.dir) !== fence.generation
|
|
828
|
+
? `Codex ${method} refused because the shared app-server generation changed`
|
|
829
|
+
: null
|
|
830
|
+
const before = generationError()
|
|
831
|
+
if (before) return Promise.resolve({ ok: false, error: before })
|
|
832
|
+
return new Promise((resolve) => {
|
|
833
|
+
const conn: Socket = createConnection(sock)
|
|
834
|
+
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
835
|
+
let upgraded = false, settled = false
|
|
836
|
+
const done = (r: { ok: true } | { ok: false; error: string }) => {
|
|
837
|
+
if (settled) return
|
|
838
|
+
settled = true
|
|
839
|
+
clearTimeout(timer)
|
|
840
|
+
try { conn.destroy() } catch {}
|
|
841
|
+
resolve(r)
|
|
842
|
+
}
|
|
843
|
+
// thread/archive may wait up to 10s in shutdown_and_wait before the server commits; keep a margin so a
|
|
844
|
+
// legitimate late response is not turned into an early commit-unknown race.
|
|
845
|
+
const timer = setTimeout(() => done({ ok: false, error: generationError() || `Codex ${method} timed out after 15s` }), 15000)
|
|
846
|
+
conn.on('error', (e) => done({ ok: false, error: generationError() || `Codex ${method} connection failed: ${rpcError(e)}` }))
|
|
847
|
+
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${method}` }) })
|
|
848
|
+
const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
|
|
849
|
+
conn.on('connect', () => {
|
|
850
|
+
const changed = generationError()
|
|
851
|
+
if (changed) return done({ ok: false, error: changed })
|
|
852
|
+
conn.write(WS_UPGRADE(randomBytes(16).toString('base64')))
|
|
853
|
+
})
|
|
854
|
+
const handle = (json: string) => {
|
|
855
|
+
let m: JsonRpc
|
|
856
|
+
try { m = JSON.parse(json) } catch { return }
|
|
857
|
+
if (m.error) return done({ ok: false, error: generationError() || `Codex ${method} failed: ${m.error.message || JSON.stringify(m.error)}` })
|
|
858
|
+
if (m.id === 1 && m.result) {
|
|
859
|
+
const changed = generationError()
|
|
860
|
+
if (changed) return done({ ok: false, error: changed })
|
|
861
|
+
send({ method: 'initialized', params: {} })
|
|
862
|
+
return send({ id: 2, method, params: { threadId } })
|
|
863
|
+
}
|
|
864
|
+
if (m.id === 2 && m.result) {
|
|
865
|
+
const changed = generationError()
|
|
866
|
+
return changed ? done({ ok: false, error: changed }) : done({ ok: true })
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
conn.on('data', (chunk: Buffer) => {
|
|
870
|
+
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
871
|
+
if (!upgraded) {
|
|
872
|
+
const i = fs.buf.indexOf('\r\n\r\n')
|
|
873
|
+
if (i < 0) return
|
|
874
|
+
const head = fs.buf.slice(0, i).toString('utf8')
|
|
875
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused WebSocket upgrade for ${method}` })
|
|
876
|
+
upgraded = true
|
|
877
|
+
fs.buf = fs.buf.slice(i + 4)
|
|
878
|
+
send(wsInitialize)
|
|
879
|
+
}
|
|
880
|
+
if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${method}` })
|
|
881
|
+
})
|
|
882
|
+
})
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
type CodexPagedIdsResult = { ok: true; ids: string[] } | { ok: false; error: string }
|
|
886
|
+
// Codex treats an omitted or empty sourceKinds filter as "interactive" defaults. Cold proof must census the
|
|
887
|
+
// entire native thread graph, including subAgent/thread-spawn rows that have no Spex record, so the adapter
|
|
888
|
+
// supplies every protocol source kind explicitly for its thread/list calls.
|
|
889
|
+
export const CODEX_THREAD_SOURCE_KINDS = [
|
|
890
|
+
'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
|
|
891
|
+
'subAgentThreadSpawn', 'subAgentOther', 'unknown',
|
|
892
|
+
] as const
|
|
893
|
+
function codexPagedIds(sock: string, method: 'thread/list' | 'thread/loaded/list', params: Record<string, unknown>, extractId: (item: unknown) => string | null, label: string): Promise<CodexPagedIdsResult> {
|
|
894
|
+
return new Promise((resolve) => {
|
|
895
|
+
const conn: Socket = createConnection(sock)
|
|
896
|
+
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
897
|
+
let upgraded = false, settled = false, requestId = 2, cursor: string | null = null
|
|
898
|
+
const ids = new Set<string>()
|
|
899
|
+
const done = (result: CodexPagedIdsResult) => {
|
|
900
|
+
if (settled) return
|
|
901
|
+
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
902
|
+
}
|
|
903
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex ${label} timed out` }), 5000)
|
|
904
|
+
conn.on('error', (error) => done({ ok: false, error: `Codex ${label} failed: ${rpcError(error)}` }))
|
|
905
|
+
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during ${label}` }) })
|
|
906
|
+
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
907
|
+
const requestPage = () => send({ id: requestId, method, params: { ...params, ...(cursor ? { cursor } : {}), limit: 100 } })
|
|
908
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
909
|
+
const handle = (json: string) => {
|
|
910
|
+
let message: JsonRpc
|
|
911
|
+
try { message = JSON.parse(json) } catch { return }
|
|
912
|
+
if (message.error) return done({ ok: false, error: `Codex ${label} failed: ${message.error.message || JSON.stringify(message.error)}` })
|
|
913
|
+
if (message.id === 1 && message.result) { send({ method: 'initialized', params: {} }); return requestPage() }
|
|
914
|
+
if (message.id !== requestId || !message.result) return
|
|
915
|
+
const page = message.result as { data?: unknown; nextCursor?: unknown }
|
|
916
|
+
if (Array.isArray(page.data)) for (const item of page.data) {
|
|
917
|
+
const id = extractId(item)
|
|
918
|
+
if (typeof id === 'string') ids.add(id)
|
|
919
|
+
}
|
|
920
|
+
cursor = typeof page.nextCursor === 'string' && page.nextCursor ? page.nextCursor : null
|
|
921
|
+
if (!cursor) return done({ ok: true, ids: [...ids] })
|
|
922
|
+
requestId++
|
|
923
|
+
requestPage()
|
|
924
|
+
}
|
|
925
|
+
conn.on('data', (chunk: Buffer) => {
|
|
926
|
+
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
927
|
+
if (!upgraded) {
|
|
928
|
+
const i = fs.buf.indexOf('\r\n\r\n')
|
|
929
|
+
if (i < 0) return
|
|
930
|
+
const head = fs.buf.slice(0, i).toString('utf8')
|
|
931
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return done({ ok: false, error: `Codex app-server refused loaded-reference census: ${head.split('\r\n')[0]}` })
|
|
932
|
+
upgraded = true; fs.buf = fs.buf.slice(i + 4)
|
|
933
|
+
send(wsInitialize)
|
|
934
|
+
}
|
|
935
|
+
if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during ${label}` })
|
|
936
|
+
})
|
|
937
|
+
})
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// Lightweight resident census: unlike the full shared-runtime probe, this scans only paginated manager IDs
|
|
941
|
+
// and never issues thread/read includeTurns for each loaded reference.
|
|
942
|
+
export async function codexLoadedReferenceIds(sock: string): Promise<{ ok: true; referenceIds: string[] } | { ok: false; error: string }> {
|
|
943
|
+
const result = await codexPagedIds(sock, 'thread/loaded/list', {}, (item) => {
|
|
944
|
+
if (typeof item === 'string') return item
|
|
945
|
+
const value = item as { id?: unknown; threadId?: unknown } | null
|
|
946
|
+
return typeof value?.id === 'string' ? value.id : typeof value?.threadId === 'string' ? value.threadId : null
|
|
947
|
+
}, 'loaded-reference census')
|
|
948
|
+
return result.ok ? { ok: true, referenceIds: result.ids } : result
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }> {
|
|
952
|
+
return new Promise((resolve) => {
|
|
953
|
+
const conn: Socket = createConnection(sock)
|
|
954
|
+
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
955
|
+
let upgraded = false, settled = false
|
|
956
|
+
const done = (result: { ok: true; turnPresence: 'idle' | 'active' | 'unknown' } | { ok: false; error: string }) => {
|
|
957
|
+
if (settled) return
|
|
958
|
+
settled = true; clearTimeout(timer); try { conn.destroy() } catch {}; resolve(result)
|
|
959
|
+
}
|
|
960
|
+
const timer = setTimeout(() => done({ ok: false, error: `Codex target thread ${threadId} read timed out after 5000ms` }), 5000)
|
|
961
|
+
conn.on('error', (error) => done({ ok: false, error: `Codex target thread ${threadId} read failed: ${rpcError(error)}` }))
|
|
962
|
+
conn.on('close', () => { if (!settled) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` }) })
|
|
963
|
+
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
964
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
965
|
+
const handle = (json: string) => {
|
|
966
|
+
let message: JsonRpc
|
|
967
|
+
try { message = JSON.parse(json) } catch { return }
|
|
968
|
+
if (message.error) return done({ ok: false, error: `Codex target thread ${threadId} read failed: ${message.error.message || JSON.stringify(message.error)}` })
|
|
969
|
+
if (message.id === 1 && message.result) {
|
|
970
|
+
send({ method: 'initialized', params: {} })
|
|
971
|
+
return send({ id: 2, method: 'thread/read', params: { threadId, includeTurns: true } })
|
|
972
|
+
}
|
|
973
|
+
if (message.id !== 2 || !message.result) return
|
|
974
|
+
const turns = (message.result as { thread?: { turns?: unknown } }).thread?.turns
|
|
975
|
+
if (!Array.isArray(turns)) return done({ ok: true, turnPresence: 'unknown' })
|
|
976
|
+
return done({ ok: true, turnPresence: activeTurnIdFromThread(message.result) ? 'active' : 'idle' })
|
|
977
|
+
}
|
|
978
|
+
conn.on('data', (chunk: Buffer) => {
|
|
979
|
+
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
980
|
+
if (!upgraded) {
|
|
981
|
+
const i = fs.buf.indexOf('\r\n\r\n')
|
|
982
|
+
if (i < 0) return
|
|
983
|
+
const head = fs.buf.slice(0, i).toString('utf8')
|
|
984
|
+
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]}` })
|
|
985
|
+
upgraded = true; fs.buf = fs.buf.slice(i + 4); send(wsInitialize)
|
|
986
|
+
}
|
|
987
|
+
if (drainWsFrames(fs, conn, handle)) done({ ok: false, error: `Codex app-server closed during target thread ${threadId} read` })
|
|
988
|
+
})
|
|
989
|
+
})
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
|
|
993
|
+
// a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
|
|
994
|
+
export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
|
|
995
|
+
const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
|
|
996
|
+
? params.sourceKinds
|
|
997
|
+
: [...CODEX_THREAD_SOURCE_KINDS]
|
|
998
|
+
return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
|
|
999
|
+
if (typeof item === 'string') return item
|
|
1000
|
+
const id = (item as { id?: unknown } | null)?.id
|
|
1001
|
+
return typeof id === 'string' ? id : null
|
|
1002
|
+
}, 'thread/list')
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
|
|
1006
|
+
const generationBefore = codexRuntimeGeneration(dir)
|
|
1007
|
+
if (!generationBefore) return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'Codex shared app-server generation is unproven' }
|
|
1008
|
+
const sock = codexAppServerSock(dir)
|
|
1009
|
+
const [loaded, activeDescendants, archivedDescendants] = await Promise.all([
|
|
1010
|
+
codexLoadedReferenceIds(sock),
|
|
1011
|
+
codexThreadList(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1012
|
+
codexThreadList(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1013
|
+
])
|
|
1014
|
+
const referenceIds = loaded.ok ? loaded.referenceIds : []
|
|
1015
|
+
const descendantIds = activeDescendants.ok && archivedDescendants.ok
|
|
1016
|
+
? [...new Set([...activeDescendants.ids, ...archivedDescendants.ids])]
|
|
1017
|
+
: []
|
|
1018
|
+
if (!loaded.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: loaded.error }
|
|
1019
|
+
if (!activeDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: activeDescendants.error }
|
|
1020
|
+
if (!archivedDescendants.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: archivedDescendants.error }
|
|
1021
|
+
let targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = 'none'
|
|
1022
|
+
if (referenceIds.includes(threadId)) {
|
|
1023
|
+
const target = await codexTargetTurnPresence(sock, threadId)
|
|
1024
|
+
if (!target.ok) return { healthy: false, referenceIds, targetTurnPresence: 'unknown', descendantIds, error: target.error }
|
|
1025
|
+
targetTurnPresence = target.turnPresence
|
|
1026
|
+
}
|
|
1027
|
+
if (codexRuntimeGeneration(dir) !== generationBefore)
|
|
1028
|
+
return { healthy: false, referenceIds, targetTurnPresence, descendantIds, error: 'shared Codex app-server generation changed during target guard' }
|
|
1029
|
+
return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
type CodexColdPreflight = { ok: true; alreadyCold?: boolean; guard: SharedRuntimeMutationGuard } | { ok: false; reason: string }
|
|
1033
|
+
async function codexColdPreflight(threadId: string, dir = runtimeRoot()): Promise<CodexColdPreflight> {
|
|
1034
|
+
const sock = codexAppServerSock(dir)
|
|
1035
|
+
const [guard, archivedList, activeList] = await Promise.all([
|
|
1036
|
+
codexTargetMutationGuard(threadId, dir),
|
|
1037
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1038
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1039
|
+
])
|
|
1040
|
+
if (guard.descendantIds.length)
|
|
1041
|
+
return { ok: false, reason: `Codex thread ${threadId} has owned descendants (${guard.descendantIds.join(', ')}); subtree archive is not proven safe` }
|
|
1042
|
+
if (!guard.healthy) return { ok: false, reason: guard.error || `Codex thread ${threadId} target state is unknown` }
|
|
1043
|
+
if (!archivedList.ok) return { ok: false, reason: archivedList.error }
|
|
1044
|
+
if (!activeList.ok) return { ok: false, reason: activeList.error }
|
|
1045
|
+
const inArchived = archivedList.ids.includes(threadId)
|
|
1046
|
+
const inActive = activeList.ids.includes(threadId)
|
|
1047
|
+
if (inArchived === inActive) return { ok: false, reason: `Codex thread ${threadId} has ambiguous disk collection state (archived=${inArchived}, active=${inActive})` }
|
|
1048
|
+
if (guard.targetTurnPresence === 'active') return { ok: false, reason: `Codex thread ${threadId} has an active turn; terminal-state confirmation is required before archive` }
|
|
1049
|
+
if (guard.targetTurnPresence === 'unknown') return { ok: false, reason: `Codex thread ${threadId} turn state is unknown; refusing to archive without terminal-state confirmation` }
|
|
1050
|
+
if (guard.targetTurnPresence === 'none')
|
|
1051
|
+
return inArchived ? { ok: true, alreadyCold: true, guard } : { ok: true, guard }
|
|
1052
|
+
if (inArchived) return { ok: false, reason: `Codex thread ${threadId} is archived but remains loaded` }
|
|
1053
|
+
return { ok: true, guard }
|
|
1054
|
+
}
|
|
1055
|
+
|
|
609
1056
|
// Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
|
|
610
1057
|
// id at launch (codexStartThread → stored on the record), this is only the DELIVERY FALLBACK for a pre-existing
|
|
611
1058
|
// session whose id was never stored: it returns the first loaded thread. On a shared per-project server several
|
|
@@ -654,13 +1101,140 @@ export function codexThreadId(sock: string): Promise<{ ok: true; threadId: strin
|
|
|
654
1101
|
})
|
|
655
1102
|
}
|
|
656
1103
|
|
|
1104
|
+
// Resource ownership asks the adapter for what the shared server actually owns now. Records are joined later;
|
|
1105
|
+
// they are never treated as references by themselves. A loaded thread is a control-plane reference and its
|
|
1106
|
+
// fresh inProgress turn (the same predicate used by delivery) distinguishes active from addressable-idle.
|
|
1107
|
+
export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRuntimeProbe> {
|
|
1108
|
+
const sock = codexAppServerSock(dir)
|
|
1109
|
+
return (async () => {
|
|
1110
|
+
// File presence is not process identity. A dead PID plus a stale socket file is the normal crash residue;
|
|
1111
|
+
// only a live PID and a live listener establish a resident control plane. This keeps a deliberately absent
|
|
1112
|
+
// root a healthy empty census while leaving live-but-ambiguous roots loud and visible.
|
|
1113
|
+
let pid = 0
|
|
1114
|
+
try { pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim()) } catch { /* absent/stale */ }
|
|
1115
|
+
const pidLive = pid > 0 && !!processStartToken(pid)
|
|
1116
|
+
const listener = await listenerAt(sock, 800)
|
|
1117
|
+
if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
|
|
1118
|
+
if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
|
|
1119
|
+
return new Promise<SharedRuntimeProbe>((resolve) => {
|
|
1120
|
+
const conn: Socket = createConnection(sock)
|
|
1121
|
+
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
1122
|
+
const references = new Map<string, SharedRuntimeProbe['references'][number]>()
|
|
1123
|
+
const requests = new Map<number, string>()
|
|
1124
|
+
const loadedRequests = new Set<number>()
|
|
1125
|
+
const loadedIds = new Set<string>()
|
|
1126
|
+
let loadedRequestId = 2
|
|
1127
|
+
let loadedCursor: string | null = null
|
|
1128
|
+
let upgraded = false
|
|
1129
|
+
let settled = false
|
|
1130
|
+
let timer: NodeJS.Timeout
|
|
1131
|
+
const done = (result: SharedRuntimeProbe) => {
|
|
1132
|
+
if (settled) return
|
|
1133
|
+
settled = true
|
|
1134
|
+
clearTimeout(timer)
|
|
1135
|
+
try { conn.destroy() } catch { /* */ }
|
|
1136
|
+
resolve(result)
|
|
1137
|
+
}
|
|
1138
|
+
const fail = (error: string) => done({ healthy: false, references: [...references.values()], error })
|
|
1139
|
+
timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000)
|
|
1140
|
+
conn.on('error', (e) => fail(`codex app-server ownership probe failed: ${rpcError(e)}`))
|
|
1141
|
+
conn.on('close', () => fail('codex app-server closed during ownership probe'))
|
|
1142
|
+
const send = (m: JsonRpc) => conn.write(wsText(JSON.stringify(m)))
|
|
1143
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
1144
|
+
const handle = (json: string) => {
|
|
1145
|
+
let m: JsonRpc
|
|
1146
|
+
try { m = JSON.parse(json) } catch { return }
|
|
1147
|
+
if (m.error) {
|
|
1148
|
+
if (typeof m.id === 'number' && loadedRequests.has(m.id)) return fail(`codex app-server loaded/list failed: ${m.error.message || JSON.stringify(m.error)}`)
|
|
1149
|
+
const request = typeof m.id === 'number' ? requests.get(m.id) : undefined
|
|
1150
|
+
if (request) {
|
|
1151
|
+
requests.delete(m.id!)
|
|
1152
|
+
if (!requests.size) done({ healthy: true, references: [...references.values()] })
|
|
1153
|
+
return
|
|
1154
|
+
}
|
|
1155
|
+
return fail(`codex app-server ownership request ${m.id ?? 'notification'} failed: ${m.error.message || JSON.stringify(m.error)}`)
|
|
1156
|
+
}
|
|
1157
|
+
if (m.id === 1 && m.result) {
|
|
1158
|
+
send({ method: 'initialized', params: {} })
|
|
1159
|
+
loadedRequests.add(loadedRequestId)
|
|
1160
|
+
return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { limit: 100 } })
|
|
1161
|
+
}
|
|
1162
|
+
if (typeof m.id === 'number' && loadedRequests.has(m.id) && m.result) {
|
|
1163
|
+
loadedRequests.delete(m.id)
|
|
1164
|
+
const data = (m.result as { data?: unknown }).data
|
|
1165
|
+
const ids = [...new Set(Array.isArray(data) ? data.flatMap((item) => {
|
|
1166
|
+
if (typeof item === 'string') return [item]
|
|
1167
|
+
const id = (item as { id?: unknown; threadId?: unknown })?.id ?? (item as { threadId?: unknown })?.threadId
|
|
1168
|
+
return typeof id === 'string' ? [id] : []
|
|
1169
|
+
}) : [])]
|
|
1170
|
+
for (const threadId of ids) loadedIds.add(threadId)
|
|
1171
|
+
const next = (m.result as { nextCursor?: unknown }).nextCursor
|
|
1172
|
+
loadedCursor = typeof next === 'string' && next ? next : null
|
|
1173
|
+
if (loadedCursor) {
|
|
1174
|
+
loadedRequestId++
|
|
1175
|
+
loadedRequests.add(loadedRequestId)
|
|
1176
|
+
return send({ id: loadedRequestId, method: 'thread/loaded/list', params: { cursor: loadedCursor, limit: 100 } })
|
|
1177
|
+
}
|
|
1178
|
+
// Continue with the complete paginated set, not just the first manager page.
|
|
1179
|
+
if (!loadedIds.size) return done({ healthy: true, references: [] })
|
|
1180
|
+
loadedIds.forEach((threadId) => {
|
|
1181
|
+
const id = 100 + requests.size
|
|
1182
|
+
references.set(threadId, { referenceId: threadId, turnPresence: 'unknown' })
|
|
1183
|
+
requests.set(id, threadId)
|
|
1184
|
+
send({ id, method: 'thread/read', params: { threadId, includeTurns: true } })
|
|
1185
|
+
})
|
|
1186
|
+
return
|
|
1187
|
+
}
|
|
1188
|
+
if (typeof m.id === 'number' && requests.has(m.id) && m.result) {
|
|
1189
|
+
const threadId = requests.get(m.id)!
|
|
1190
|
+
requests.delete(m.id)
|
|
1191
|
+
const thread = (m.result as { thread?: { turns?: Array<{ id?: string; status?: string }> } }).thread
|
|
1192
|
+
const turnId = activeTurnIdFromThread(m.result)
|
|
1193
|
+
references.set(threadId, {
|
|
1194
|
+
referenceId: threadId,
|
|
1195
|
+
turnPresence: !Array.isArray(thread?.turns) ? 'unknown' : turnId ? 'active' : 'idle',
|
|
1196
|
+
...(turnId ? { turnId } : {}),
|
|
1197
|
+
})
|
|
1198
|
+
if (!requests.size) done({ healthy: true, references: [...references.values()] })
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
conn.on('data', (chunk: Buffer) => {
|
|
1202
|
+
fs.buf = Buffer.concat([fs.buf, chunk])
|
|
1203
|
+
if (!upgraded) {
|
|
1204
|
+
const i = fs.buf.indexOf('\r\n\r\n')
|
|
1205
|
+
if (i < 0) return
|
|
1206
|
+
const head = fs.buf.slice(0, i).toString('utf8')
|
|
1207
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return fail(`codex app-server refused ownership probe: ${head.split('\r\n')[0]}`)
|
|
1208
|
+
upgraded = true
|
|
1209
|
+
fs.buf = fs.buf.slice(i + 4)
|
|
1210
|
+
send(wsInitialize)
|
|
1211
|
+
}
|
|
1212
|
+
if (drainWsFrames(fs, conn, handle)) fail('codex app-server closed during ownership probe')
|
|
1213
|
+
})
|
|
1214
|
+
})
|
|
1215
|
+
})()
|
|
1216
|
+
}
|
|
1217
|
+
|
|
657
1218
|
// @@@ codexStartThread - the BACKEND owns the thread. On the shared PER-PROJECT app-server we `thread/start
|
|
658
1219
|
// { cwd }` (codex resolves config/hooks/AGENTS.md from that worktree cwd — exactly as claude loads CLAUDE.md
|
|
659
1220
|
// per-worktree — so one project-scoped server behaves analogously to a per-worktree launch), and the result
|
|
660
1221
|
// carries the new thread id (`result.thread.id`). The launcher stores that id on the governed record and
|
|
661
1222
|
// fires the first turn; there is no capture hook and no rollout/cwd scan. Same WS framing as codexThreadId.
|
|
662
1223
|
// Never throws.
|
|
663
|
-
|
|
1224
|
+
// @@@ codexStartThreadParams - what a BACKEND-owned thread is created with. `config` is the per-request
|
|
1225
|
+
// override map (the only channel that reaches a thread): `bypass_hook_trust` so our hooks run, and
|
|
1226
|
+
// `shell_environment_policy.set` so every command this thread spawns carries the governed record id. The
|
|
1227
|
+
// latter is codex's answer to a structural fact — a codex tool shell descends from the SHARED app-server, so
|
|
1228
|
+
// it must inherit no identity and be given its own instead (verified live: the shell reports exactly the
|
|
1229
|
+
// injected id, and the launcher's env leaks nothing).
|
|
1230
|
+
export function codexStartThreadParams(cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Record<string, unknown> {
|
|
1231
|
+
const config = {
|
|
1232
|
+
...(bypassHookTrust ? { bypass_hook_trust: true } : {}),
|
|
1233
|
+
...(shellEnv && Object.keys(shellEnv).length ? { shell_environment_policy: { set: shellEnv } } : {}),
|
|
1234
|
+
}
|
|
1235
|
+
return { ...(cwd ? { cwd } : {}), ...(Object.keys(config).length ? { config } : {}) }
|
|
1236
|
+
}
|
|
1237
|
+
export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = false, shellEnv?: Record<string, string>): Promise<{ ok: true; threadId: string } | { ok: false; error: string }> {
|
|
664
1238
|
return new Promise((resolve) => {
|
|
665
1239
|
const conn: Socket = createConnection(sock)
|
|
666
1240
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
@@ -690,8 +1264,13 @@ export function codexStartThread(sock: string, cwd?: string, bypassHookTrust = f
|
|
|
690
1264
|
// own `--remote resume` TUI client injects it. Without it the worktree's UNtrusted `.codex` config layer
|
|
691
1265
|
// stays disabled → no local hooks discovered → no Stop gate. Only on the bypass path (older codex without
|
|
692
1266
|
// the flag uses writeCodexTrust's hash and never sees this key).
|
|
693
|
-
|
|
694
|
-
|
|
1267
|
+
// The same override map carries the thread's IDENTITY. A codex tool shell is spawned by the SHARED
|
|
1268
|
+
// app-server, so it can inherit no session id — and must not, that leak was github#76. Codex's own
|
|
1269
|
+
// `shell_environment_policy.set` injects vars into every command THIS thread spawns, so the backend
|
|
1270
|
+
// stamps the governed record id there at thread creation, the same moment and the same knowledge with
|
|
1271
|
+
// which a claude launch bakes it into its agent's env. Identity then arrives per-thread, needing no
|
|
1272
|
+
// alias, no store lookup, and no cwd anywhere downstream.
|
|
1273
|
+
return send({ id: 2, method: 'thread/start', params: codexStartThreadParams(cwd, bypassHookTrust, shellEnv) })
|
|
695
1274
|
}
|
|
696
1275
|
if (m.id === 2 && m.result) {
|
|
697
1276
|
const tid = (m.result as { thread?: { id?: string } })?.thread?.id
|
|
@@ -1129,10 +1708,57 @@ export function opencodeLaunchCommand(opencodeCmd = 'opencode'): string {
|
|
|
1129
1708
|
return `bash -lc ${shQuote(script)} spexcode-opencode`
|
|
1130
1709
|
}
|
|
1131
1710
|
|
|
1711
|
+
const socketListenerLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, _pane, socketLive) =>
|
|
1712
|
+
(tmuxAlive && !!socketLive ? 'online' : 'offline')
|
|
1713
|
+
|
|
1714
|
+
const socketListenerOrPidAliveLiveness: Harness['liveness'] = (_rec, tmuxAlive, _runtimeDir, pane, socketLive) =>
|
|
1715
|
+
(tmuxAlive && (!!socketLive || pane?.pidAlive === true) ? 'online' : 'offline')
|
|
1716
|
+
|
|
1717
|
+
const recordOnline: Harness['liveness'] = (rec) => rec.stopped ? 'offline' : 'online'
|
|
1718
|
+
|
|
1719
|
+
// @@@ unlinkSocks - remove ONLY the transport this teardown PROVED dead. `cleanupRuntime` unlinks *their*
|
|
1720
|
+
// socket, and the honest test of "theirs" is that the agent it just killed is GONE. It used to unlink on
|
|
1721
|
+
// faith, which is unsound because a socket path is derived from the session id ALONE: it is the one
|
|
1722
|
+
// per-session resource NOT scoped by the store (`SPEXCODE_HOME`) or the tmux server (`SPEXCODE_TMUX`), so an
|
|
1723
|
+
// isolated instance closing an id that also names a LIVE session elsewhere had its `kill-session` miss (that
|
|
1724
|
+
// IS namespaced) while this unlink landed (it is not) — deleting a working agent's socket out from under it.
|
|
1725
|
+
// The damage is invisible and permanent: the listener stays bound to an unlinked path, so nothing can ever
|
|
1726
|
+
// connect again (delivery fails its existsSync gate) and every probe ENOENTs, which the liveness axis reads as
|
|
1727
|
+
// PROVEN death — a live worker reading `offline`, which in turn disarms the relaunch guard.
|
|
1728
|
+
// So: poll until death is proven, then unlink. A listener still answering past the wall is somebody's live
|
|
1729
|
+
// agent — mine that failed to die, or one that was never mine — and either way it is not ours to remove: leave
|
|
1730
|
+
// it and say so. `unproven` is not proof either, so it is left too. The asymmetry is deliberate: a dead-but-
|
|
1731
|
+
// unlinked file is harmless residue the next teardown reaps, while a wrong unlink strands a working agent.
|
|
1732
|
+
const SOCK_DEATH_WALL_MS = 2000 // a killed agent releases its listener in well under this; the wall only bounds the wrong case
|
|
1733
|
+
const SOCK_DEATH_POLL_MS = 100
|
|
1734
|
+
export const unlinkSocks = async (...paths: string[]): Promise<void> => {
|
|
1735
|
+
for (const path of paths) {
|
|
1736
|
+
if (!existsSync(path)) continue
|
|
1737
|
+
const deadline = Date.now() + SOCK_DEATH_WALL_MS
|
|
1738
|
+
let probe = await listenerAt(path)
|
|
1739
|
+
while (probe !== 'dead' && Date.now() < deadline) {
|
|
1740
|
+
await new Promise((r) => setTimeout(r, SOCK_DEATH_POLL_MS))
|
|
1741
|
+
probe = await listenerAt(path)
|
|
1742
|
+
}
|
|
1743
|
+
if (probe !== 'dead') {
|
|
1744
|
+
console.warn(`spex: left ${path} in place — ${probe === 'live'
|
|
1745
|
+
? 'a listener is still answering it, so it belongs to a running agent (this teardown did not kill it, or it was never ours)'
|
|
1746
|
+
: 'the listener probe could not conclude, and death was never proven'}`)
|
|
1747
|
+
continue
|
|
1748
|
+
}
|
|
1749
|
+
try { rmSync(path, { force: true }) } catch { /* already gone */ }
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
|
|
1753
|
+
const rendezvousLaunchEnv = (id: string): string[] => [
|
|
1754
|
+
'CLAUDE_BG_BACKEND=daemon',
|
|
1755
|
+
`CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`,
|
|
1756
|
+
]
|
|
1757
|
+
const noLaunchEnv = (): string[] => []
|
|
1758
|
+
|
|
1132
1759
|
export const claudeHarness: Harness = {
|
|
1133
1760
|
id: 'claude',
|
|
1134
1761
|
headless: false,
|
|
1135
|
-
messageStream: false,
|
|
1136
1762
|
events: CLAUDE_EVENTS,
|
|
1137
1763
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
1138
1764
|
paneTitleIsSelfSummary: true, // claude writes its live task summary into the OSC pane title → headline derives from it
|
|
@@ -1140,6 +1766,7 @@ export const claudeHarness: Harness = {
|
|
|
1140
1766
|
baseCmd: claudeBaseCmd,
|
|
1141
1767
|
sessionIdArg: (id) => `--session-id ${id}`, // the caller chooses the id
|
|
1142
1768
|
sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
|
|
1769
|
+
launchEnv: rendezvousLaunchEnv,
|
|
1143
1770
|
shimFile: (proj) => join(proj, '.claude', 'settings.json'),
|
|
1144
1771
|
worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
|
|
1145
1772
|
contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
|
|
@@ -1153,9 +1780,11 @@ export const claudeHarness: Harness = {
|
|
|
1153
1780
|
// online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
|
|
1154
1781
|
// the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
|
|
1155
1782
|
// dead-pane-reads-working bug). See rendezvousListening.
|
|
1156
|
-
liveness:
|
|
1783
|
+
liveness: socketListenerLiveness,
|
|
1784
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
1157
1785
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1158
|
-
cleanupRuntime: (rec) =>
|
|
1786
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
1787
|
+
coldRuntime: async () => ({ ok: true }),
|
|
1159
1788
|
// the TUI's sessions panel ("← for agents"): a reply injected here is parsed + enqueued to the PANEL context
|
|
1160
1789
|
// and never drained (verified live: `queue-operation: enqueue` with no dequeue, no turn, daemon silent), so
|
|
1161
1790
|
// the parse-confirmed delivery above would still report a false success into it. Matched on the panel's own
|
|
@@ -1166,6 +1795,11 @@ export const claudeHarness: Harness = {
|
|
|
1166
1795
|
? '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
1796
|
: null,
|
|
1168
1797
|
resumeArg: (rec) => `--resume ${rec.session}`,
|
|
1798
|
+
// claude's settled launch failures, in its own words: a `--resume` id it has no conversation for (the id was
|
|
1799
|
+
// never claude's, or its transcript is gone), and a rejected credential. Both are the same command failing
|
|
1800
|
+
// the same way every time — the human must repair the conversation or the login, so the transport stops at
|
|
1801
|
+
// one attempt and shows this line instead of burying it under two more identical failures.
|
|
1802
|
+
fatalLaunchOutput: ['No conversation found with session ID', 'Invalid API key', 'Please run /login'],
|
|
1169
1803
|
}
|
|
1170
1804
|
|
|
1171
1805
|
// Claude headless is a separate harness, not a claude mode. Its materialize half is exactly Claude's and is
|
|
@@ -1174,23 +1808,25 @@ export const claudeHeadlessHarness: Harness = {
|
|
|
1174
1808
|
...claudeHarness,
|
|
1175
1809
|
id: 'claude-headless',
|
|
1176
1810
|
headless: true,
|
|
1177
|
-
|
|
1811
|
+
runtimeOwnership: 'adapter',
|
|
1178
1812
|
ownsRendezvous: false,
|
|
1179
1813
|
paneTitleIsSelfSummary: false,
|
|
1180
1814
|
launchCmd: (id, runtimeDir, cmd) => claudeHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), claudeBaseCmd(cmd)),
|
|
1181
|
-
|
|
1182
|
-
//
|
|
1183
|
-
|
|
1815
|
+
launchEnv: noLaunchEnv,
|
|
1816
|
+
// Liveness is the intact, non-stopped record's property. A missing controller/child fails loudly at control
|
|
1817
|
+
// time rather than turning an idle (no child) session into a speculative offline row.
|
|
1818
|
+
liveness: recordOnline,
|
|
1184
1819
|
deliver: deliverViaClaudeHeadless,
|
|
1185
1820
|
interrupt: interruptClaudeHeadless,
|
|
1186
|
-
cleanupRuntime: (rec) =>
|
|
1821
|
+
cleanupRuntime: (rec) => unlinkSocks(claudeHeadlessSock(rec.session)),
|
|
1822
|
+
coldRuntime: async () => ({ ok: false, reason: 'claude-headless has no exact resident unload verification' }),
|
|
1187
1823
|
deliveryBlockedBy: undefined,
|
|
1188
1824
|
}
|
|
1189
1825
|
|
|
1190
1826
|
export const codexHarness: Harness = {
|
|
1191
1827
|
id: 'codex',
|
|
1192
1828
|
headless: false,
|
|
1193
|
-
|
|
1829
|
+
sharedRuntimeSpawn: true,
|
|
1194
1830
|
events: CODEX_EVENTS,
|
|
1195
1831
|
ownsRendezvous: false, // no reclaude daemon — liveness + prompts through the project app-server socket
|
|
1196
1832
|
paneTitleIsSelfSummary: false, // codex's pane title is a spinner + the cwd folder name, NOT a task summary → headline uses the prompt
|
|
@@ -1198,6 +1834,7 @@ export const codexHarness: Harness = {
|
|
|
1198
1834
|
baseCmd: codexBaseCmd,
|
|
1199
1835
|
sessionIdArg: () => '', // codex assigns its own id (the backend owns it via thread/start)
|
|
1200
1836
|
sessionEnvVar: 'CODEX_THREAD_ID',
|
|
1837
|
+
launchEnv: noLaunchEnv,
|
|
1201
1838
|
// Codex discovers a LINKED worktree's PROJECT hooks from the ROOT CHECKOUT's `.codex`, NOT the worktree's
|
|
1202
1839
|
// (codex-rs `root_checkout_hooks_folder_for_dir` rewrites the hooks-config folder to <repo_root>/<rel>/.codex
|
|
1203
1840
|
// for any linked worktree). Every worktree's thread (cwd = worktree root) therefore reads the SAME
|
|
@@ -1252,12 +1889,236 @@ export const codexHarness: Harness = {
|
|
|
1252
1889
|
if (pane?.pidAlive !== undefined) return pane.pidAlive ? 'online' : 'offline'
|
|
1253
1890
|
return paneTreeRunsCodex(pane) ? 'online' : 'offline'
|
|
1254
1891
|
},
|
|
1892
|
+
leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
|
|
1255
1893
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
1256
|
-
cleanupRuntime: () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
1894
|
+
cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
1895
|
+
coldRetirementPreflight: async (rec) => {
|
|
1896
|
+
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
1897
|
+
const threadId = rec.harnessSessionId
|
|
1898
|
+
const dir = runtimeRoot()
|
|
1899
|
+
const generationBefore = codexRuntimeGeneration(dir)
|
|
1900
|
+
if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
1901
|
+
const sock = codexAppServerSock(dir)
|
|
1902
|
+
const [guard, archivedList, activeList] = await Promise.all([
|
|
1903
|
+
codexTargetMutationGuard(threadId, dir),
|
|
1904
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1905
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1906
|
+
])
|
|
1907
|
+
if (codexRuntimeGeneration(dir) !== generationBefore)
|
|
1908
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
|
|
1909
|
+
if (guard.descendantIds.length) return { ok: false, reason: `Codex thread ${threadId} has owned descendants (${guard.descendantIds.join(', ')}); cold retirement is not proven safe` }
|
|
1910
|
+
if (!guard.healthy) return { ok: false, reason: guard.error || `Codex thread ${threadId} target state is unknown` }
|
|
1911
|
+
if (!archivedList.ok) return { ok: false, reason: archivedList.error }
|
|
1912
|
+
if (!activeList.ok) return { ok: false, reason: activeList.error }
|
|
1913
|
+
if (guard.targetTurnPresence !== 'none') return { ok: false, reason: `Codex thread ${threadId} is still loaded` }
|
|
1914
|
+
const inArchived = archivedList.ids.includes(threadId)
|
|
1915
|
+
const inActive = activeList.ids.includes(threadId)
|
|
1916
|
+
if (!inArchived || inActive) return { ok: false, reason: `Codex thread ${threadId} is not uniquely in the archived collection (archived=${inArchived}, active=${inActive})` }
|
|
1917
|
+
return { ok: true, alreadyCold: true }
|
|
1918
|
+
},
|
|
1919
|
+
coldPreflight: async (rec) => {
|
|
1920
|
+
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
1921
|
+
const result = await codexColdPreflight(rec.harnessSessionId)
|
|
1922
|
+
return result.ok ? { ok: true, ...(result.alreadyCold ? { alreadyCold: true } : {}) } : result
|
|
1923
|
+
},
|
|
1924
|
+
coldRuntime: async (rec) => {
|
|
1925
|
+
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
1926
|
+
const threadId = rec.harnessSessionId
|
|
1927
|
+
const sock = codexAppServerSock(runtimeRoot())
|
|
1928
|
+
const generationBefore = codexRuntimeGeneration(runtimeRoot())
|
|
1929
|
+
if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
1930
|
+
const preflight = await codexColdPreflight(threadId)
|
|
1931
|
+
if (preflight && !preflight.ok) return preflight
|
|
1932
|
+
if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore) return { ok: false, reason: 'shared Codex app-server generation changed during target guard' }
|
|
1933
|
+
if (preflight?.ok && preflight.alreadyCold) return { ok: true }
|
|
1934
|
+
const siblingBefore = preflight.guard.referenceIds.filter((referenceId) => referenceId !== threadId)
|
|
1935
|
+
const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
|
|
1936
|
+
const [guard, archivedList, activeList] = await Promise.all([
|
|
1937
|
+
codexTargetMutationGuard(threadId),
|
|
1938
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1939
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1940
|
+
])
|
|
1941
|
+
if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
|
|
1942
|
+
if (guard.descendantIds.length) return { ok: false, reason: `Codex thread ${threadId} acquired owned descendants during archive (${guard.descendantIds.join(', ')})` }
|
|
1943
|
+
if (!guard.healthy) return { ok: false, reason: guard.error || 'Codex post-archive target guard is unhealthy' }
|
|
1944
|
+
if (!archivedList.ok) return { ok: false, reason: archivedList.error }
|
|
1945
|
+
if (!activeList.ok) return { ok: false, reason: activeList.error }
|
|
1946
|
+
if (guard.targetTurnPresence !== 'none') return { ok: false, reason: `Codex thread ${threadId} remains loaded after thread/archive` }
|
|
1947
|
+
if (!archivedList.ids.includes(threadId)) return { ok: false, reason: `Codex thread ${threadId} is absent from both loaded and archived collections` }
|
|
1948
|
+
if (activeList.ids.includes(threadId)) return { ok: false, reason: `Codex thread ${threadId} remains in the non-archived collection` }
|
|
1949
|
+
const afterIds = new Set(guard.referenceIds.filter((referenceId) => referenceId !== threadId))
|
|
1950
|
+
if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
|
|
1951
|
+
return { ok: true }
|
|
1952
|
+
}
|
|
1953
|
+
const fence = { dir: runtimeRoot(), generation: generationBefore }
|
|
1954
|
+
const archived = await codexThreadMutation(sock, 'thread/archive', rec.harnessSessionId, fence)
|
|
1955
|
+
if (!archived.ok) {
|
|
1956
|
+
if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore)
|
|
1957
|
+
return { ok: false, reason: `${archived.error}; shared Codex app-server generation changed, so archive state is unknown and no compensation was attempted` }
|
|
1958
|
+
// RPC transport failure is commit-unknown. Reconcile collections before deciding whether compensation is needed.
|
|
1959
|
+
const [archivedList, activeList] = await Promise.all([
|
|
1960
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1961
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1962
|
+
])
|
|
1963
|
+
if (archivedList.ok && archivedList.ids.includes(rec.harnessSessionId)) {
|
|
1964
|
+
const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId, fence)
|
|
1965
|
+
const stillActive = await codexThreadList(sock, { archived: false, sourceKinds: [] })
|
|
1966
|
+
const suffix = restored.ok && stillActive.ok && stillActive.ids.includes(rec.harnessSessionId) ? '' : '; compensation/reconciliation failed'
|
|
1967
|
+
return { ok: false, reason: `${archived.error}${suffix}` }
|
|
1968
|
+
}
|
|
1969
|
+
return { ok: false, reason: archivedList.ok && activeList.ok ? archived.error : `${archived.error}; archive state is unknown and could not be reconciled` }
|
|
1970
|
+
}
|
|
1971
|
+
let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
|
|
1972
|
+
const verifyDeadline = Date.now() + 30_000
|
|
1973
|
+
for (let attempt = 0; attempt < 6 && Date.now() < verifyDeadline; attempt++) {
|
|
1974
|
+
verified = await coldCheck()
|
|
1975
|
+
if (verified.ok) break
|
|
1976
|
+
if (Date.now() < verifyDeadline) await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1977
|
+
}
|
|
1978
|
+
if (verified.ok) return verified
|
|
1979
|
+
if (codexRuntimeGeneration(runtimeRoot()) !== generationBefore)
|
|
1980
|
+
return { ok: false, reason: `${verified.reason}; shared Codex app-server generation changed, so no compensation was attempted` }
|
|
1981
|
+
const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId, fence)
|
|
1982
|
+
const active = await codexThreadList(sock, { archived: false, sourceKinds: [] })
|
|
1983
|
+
const suffix = restored.ok && active.ok && active.ids.includes(rec.harnessSessionId) ? '' : '; compensation failed or archive state is unknown'
|
|
1984
|
+
return { ok: false, reason: `${verified.reason}${suffix}` }
|
|
1985
|
+
},
|
|
1986
|
+
restoreRuntime: async (rec) => {
|
|
1987
|
+
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
1988
|
+
const sock = codexAppServerSock(runtimeRoot())
|
|
1989
|
+
const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
|
|
1990
|
+
const [active, archived] = await Promise.all([
|
|
1991
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1992
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1993
|
+
])
|
|
1994
|
+
if (!active.ok || !archived.ok) return { ok: false, reason: 'Codex restore state could not be reconciled' }
|
|
1995
|
+
const inActive = active.ids.includes(rec.harnessSessionId!)
|
|
1996
|
+
const inArchived = archived.ids.includes(rec.harnessSessionId!)
|
|
1997
|
+
if (inActive && !inArchived) return { ok: true }
|
|
1998
|
+
if (inArchived && !inActive) return { ok: false, reason: 'Codex thread remains archived; restore can be retried' }
|
|
1999
|
+
return { ok: false, reason: 'Codex restore state is ambiguous (thread in both or neither collection)' }
|
|
2000
|
+
}
|
|
2001
|
+
const restored = await codexThreadMutation(sock, 'thread/unarchive', rec.harnessSessionId)
|
|
2002
|
+
if (!restored.ok) return reconcile()
|
|
2003
|
+
return reconcile()
|
|
2004
|
+
},
|
|
2005
|
+
sharedRuntimes: (runtimeDir) => [{
|
|
2006
|
+
key: 'codex-app-server',
|
|
2007
|
+
label: 'Codex app-server',
|
|
2008
|
+
pidFile: codexAppServerPid(runtimeDir),
|
|
2009
|
+
isolationFile: codexAppServerIsolation(runtimeDir),
|
|
2010
|
+
residency: async () => {
|
|
2011
|
+
const sock = codexAppServerSock(runtimeDir)
|
|
2012
|
+
let pid = 0
|
|
2013
|
+
try { pid = Number(readFileSync(codexAppServerPid(runtimeDir), 'utf8').trim()) } catch { /* stale/missing pid */ }
|
|
2014
|
+
const pidLive = pid > 0 && !!processStartToken(pid)
|
|
2015
|
+
const listener = await listenerAt(sock, 800)
|
|
2016
|
+
if (!pidLive && listener === 'dead') return { healthy: true, referenceIds: [], rootAbsent: true }
|
|
2017
|
+
if (pidLive && (!codexRuntimeGeneration(runtimeDir) || listener !== 'live'))
|
|
2018
|
+
return { healthy: false, referenceIds: [], error: 'Codex shared root identity/socket generation is not proven' }
|
|
2019
|
+
if (!pidLive || listener !== 'live')
|
|
2020
|
+
return { healthy: false, referenceIds: [], error: 'Codex shared root state is unknown' }
|
|
2021
|
+
const result = await codexLoadedReferenceIds(sock)
|
|
2022
|
+
return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
|
|
2023
|
+
},
|
|
2024
|
+
mutationGuard: (targetReferenceId) => codexTargetMutationGuard(targetReferenceId, runtimeDir),
|
|
2025
|
+
probe: () => codexSharedRuntimeProbe(runtimeDir),
|
|
2026
|
+
}],
|
|
1257
2027
|
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
1258
2028
|
// a tail handed to a bare `codex` — the script's final `codex … resume "$tid"` performs codex's own resume on
|
|
1259
2029
|
// the owned id, the SAME conversation); none → empty tail → relaunch a FRESH thread on the same worktree/record.
|
|
1260
2030
|
resumeArg: (rec) => (rec.harnessSessionId ? `--resume ${rec.harnessSessionId}` : ''),
|
|
2031
|
+
// codex's own settled failure: a thread id whose rollout is not on disk can never be resumed, so the launch
|
|
2032
|
+
// that says so has already decided. (Its transient sibling — the rollout still being written — is handled
|
|
2033
|
+
// BEFORE launch by waitForCodexRollout, so what reaches here is the permanent case.)
|
|
2034
|
+
fatalLaunchOutput: ['no rollout found for thread id'],
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
type CodexHeadlessLaunchReadinessProof = Readonly<{
|
|
2038
|
+
kind: 'codex-headless-shared-runtime'
|
|
2039
|
+
descriptorKey: string
|
|
2040
|
+
generation: CodexRuntimeGenerationProof
|
|
2041
|
+
target: Readonly<{
|
|
2042
|
+
sessionId: string
|
|
2043
|
+
threadId: string
|
|
2044
|
+
ownerSessionId: string
|
|
2045
|
+
ownerCount: 1
|
|
2046
|
+
ownerState: 'governed'
|
|
2047
|
+
referenceState: 'loaded'
|
|
2048
|
+
protectsControlPlane: true
|
|
2049
|
+
}>
|
|
2050
|
+
}>
|
|
2051
|
+
|
|
2052
|
+
const sameCodexHeadlessReadinessProof = (left: CodexHeadlessLaunchReadinessProof, right: CodexHeadlessLaunchReadinessProof) =>
|
|
2053
|
+
left.kind === right.kind &&
|
|
2054
|
+
left.descriptorKey === right.descriptorKey &&
|
|
2055
|
+
codexRuntimeGenerationToken(left.generation) === codexRuntimeGenerationToken(right.generation) &&
|
|
2056
|
+
left.target.sessionId === right.target.sessionId &&
|
|
2057
|
+
left.target.threadId === right.target.threadId &&
|
|
2058
|
+
left.target.ownerSessionId === right.target.ownerSessionId &&
|
|
2059
|
+
left.target.ownerCount === right.target.ownerCount &&
|
|
2060
|
+
left.target.ownerState === right.target.ownerState &&
|
|
2061
|
+
left.target.referenceState === right.target.referenceState &&
|
|
2062
|
+
left.target.protectsControlPlane === right.target.protectsControlPlane
|
|
2063
|
+
|
|
2064
|
+
const governedSharedRuntimeOwners = (runtimeDir: string, descriptorKey: string, threadId: string): string[] | null => {
|
|
2065
|
+
const root = join(runtimeDir, 'sessions')
|
|
2066
|
+
let entries
|
|
2067
|
+
try { entries = readdirSync(root, { withFileTypes: true }) }
|
|
2068
|
+
catch (error) { return (error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : null }
|
|
2069
|
+
const owners: string[] = []
|
|
2070
|
+
for (const entry of entries) {
|
|
2071
|
+
if (!entry.isDirectory()) continue
|
|
2072
|
+
let parsed: unknown
|
|
2073
|
+
try { parsed = JSON.parse(readFileSync(join(root, entry.name, 'session.json'), 'utf8')) }
|
|
2074
|
+
catch (error) {
|
|
2075
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue
|
|
2076
|
+
return null
|
|
2077
|
+
}
|
|
2078
|
+
if (!parsed || typeof parsed !== 'object') return null
|
|
2079
|
+
const record = parsed as { session_id?: unknown; governed?: unknown; harness?: unknown; harness_session_id?: unknown }
|
|
2080
|
+
if (typeof record.session_id !== 'string') return null
|
|
2081
|
+
if (record.governed !== true) continue
|
|
2082
|
+
const harnessId = typeof record.harness === 'string' && record.harness ? record.harness : defaultHarness.id
|
|
2083
|
+
let sharesDescriptor = false
|
|
2084
|
+
try { sharesDescriptor = (harnessById(harnessId).sharedRuntimes?.(runtimeDir) ?? []).some((descriptor) => descriptor.key === descriptorKey) }
|
|
2085
|
+
catch { return null }
|
|
2086
|
+
if (sharesDescriptor && record.harness_session_id === threadId) owners.push(record.session_id)
|
|
2087
|
+
}
|
|
2088
|
+
return owners
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
async function codexHeadlessReadinessProof(current: () => HarnessLaunchReadyRecord | null): Promise<CodexHeadlessLaunchReadinessProof | null> {
|
|
2092
|
+
const record = current()
|
|
2093
|
+
if (!record?.governed || record.stopped || record.archived || !record.harnessSessionId) return null
|
|
2094
|
+
const descriptor = codexHeadlessHarness.sharedRuntimes?.(record.runtimeDir)
|
|
2095
|
+
.find((candidate) => candidate.key === 'codex-app-server')
|
|
2096
|
+
if (!descriptor?.residency) return null
|
|
2097
|
+
const generationBefore = codexRuntimeGenerationProof(record.runtimeDir)
|
|
2098
|
+
if (!generationBefore) return null
|
|
2099
|
+
let resident: Awaited<ReturnType<NonNullable<SharedRuntimeDescriptor['residency']>>>
|
|
2100
|
+
try { resident = await descriptor.residency() }
|
|
2101
|
+
catch { return null }
|
|
2102
|
+
if (!resident.healthy) return null
|
|
2103
|
+
if (!resident.referenceIds.includes(record.harnessSessionId)) return null
|
|
2104
|
+
const owners = governedSharedRuntimeOwners(record.runtimeDir, descriptor.key, record.harnessSessionId)
|
|
2105
|
+
if (!owners || owners.length !== 1 || owners[0] !== record.session) return null
|
|
2106
|
+
const generationAfter = codexRuntimeGenerationProof(record.runtimeDir)
|
|
2107
|
+
if (!generationAfter || codexRuntimeGenerationToken(generationBefore) !== codexRuntimeGenerationToken(generationAfter)) return null
|
|
2108
|
+
return Object.freeze({
|
|
2109
|
+
kind: 'codex-headless-shared-runtime',
|
|
2110
|
+
descriptorKey: descriptor.key,
|
|
2111
|
+
generation: generationAfter,
|
|
2112
|
+
target: Object.freeze({
|
|
2113
|
+
sessionId: record.session,
|
|
2114
|
+
threadId: record.harnessSessionId,
|
|
2115
|
+
ownerSessionId: owners[0],
|
|
2116
|
+
ownerCount: 1,
|
|
2117
|
+
ownerState: 'governed',
|
|
2118
|
+
referenceState: 'loaded',
|
|
2119
|
+
protectsControlPlane: true,
|
|
2120
|
+
}),
|
|
2121
|
+
})
|
|
1261
2122
|
}
|
|
1262
2123
|
|
|
1263
2124
|
// Codex headless is an independent adapter: its materialization and app-server delivery are exactly Codex's,
|
|
@@ -1267,12 +2128,27 @@ export const codexHeadlessHarness: Harness = {
|
|
|
1267
2128
|
...codexHarness,
|
|
1268
2129
|
id: 'codex-headless',
|
|
1269
2130
|
headless: true,
|
|
2131
|
+
runtimeOwnership: 'adapter',
|
|
1270
2132
|
launchOneShot: true,
|
|
1271
|
-
messageStream: false,
|
|
1272
2133
|
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:
|
|
2134
|
+
// Record-backed liveness is the family contract for sleeping headless threads. An explicit stop is the one
|
|
2135
|
+
// offline marker; other app-server/thread failures surface through delivery rather than speculative liveness.
|
|
2136
|
+
liveness: recordOnline,
|
|
2137
|
+
launchReady: async (current, deadline) => {
|
|
2138
|
+
for (;;) {
|
|
2139
|
+
const proof = await codexHeadlessReadinessProof(current)
|
|
2140
|
+
if (proof) return {
|
|
2141
|
+
proof,
|
|
2142
|
+
validate: async (latest) => {
|
|
2143
|
+
const currentProof = await codexHeadlessReadinessProof(latest)
|
|
2144
|
+
return !!currentProof && sameCodexHeadlessReadinessProof(proof, currentProof)
|
|
2145
|
+
},
|
|
2146
|
+
}
|
|
2147
|
+
const remaining = deadline - Date.now()
|
|
2148
|
+
if (remaining <= 0) return null
|
|
2149
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(200, remaining)))
|
|
2150
|
+
}
|
|
2151
|
+
},
|
|
1276
2152
|
// There is no TUI to restart and the project app-server keeps the thread addressable. A forced reopen therefore
|
|
1277
2153
|
// runs the headless launch's empty-tail no-op; normal resume remains guarded by record-backed online liveness.
|
|
1278
2154
|
resumeArg: () => '',
|
|
@@ -1283,8 +2159,8 @@ export const codexHeadlessHarness: Harness = {
|
|
|
1283
2159
|
// lives IN the worktree, and the rendezvous prompt/liveness channel is REUSED wholesale — pi has no external
|
|
1284
2160
|
// hook binding (its lifecycle surface is the in-process extension API), so the shim is a GENERATED TypeScript
|
|
1285
2161
|
// 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
|
|
2162
|
+
// dispatch.sh AND binds this session's rendezvous socket itself (the adapter's launchEnv exports
|
|
2163
|
+
// CLAUDE_BG_RENDEZVOUS_SOCK) speaking the reclaude line protocol — so
|
|
1288
2164
|
// deliverViaRendezvous and the socket-listener liveness work UNCHANGED. Trust: pi gates project-local
|
|
1289
2165
|
// extensions behind saved per-directory trust (~/.pi/agent/trust.json), so writeTrust stamps the main
|
|
1290
2166
|
// checkout there (the nearest-parent lookup covers nested worktrees) and the launch carries `--approve` as
|
|
@@ -1292,7 +2168,6 @@ export const codexHeadlessHarness: Harness = {
|
|
|
1292
2168
|
export const piHarness: Harness = {
|
|
1293
2169
|
id: 'pi',
|
|
1294
2170
|
headless: false,
|
|
1295
|
-
messageStream: false,
|
|
1296
2171
|
events: PI_EVENTS,
|
|
1297
2172
|
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
1298
2173
|
paneTitleIsSelfSummary: false, // pi's pane title is not an agent-written task summary → headline uses the prompt preview
|
|
@@ -1300,6 +2175,7 @@ export const piHarness: Harness = {
|
|
|
1300
2175
|
baseCmd: piBaseCmd,
|
|
1301
2176
|
sessionIdArg: (id) => `--session-id ${id}`, // caller pins the exact session id, claude-style (created if missing)
|
|
1302
2177
|
sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
|
|
2178
|
+
launchEnv: rendezvousLaunchEnv,
|
|
1303
2179
|
shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
|
|
1304
2180
|
worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
|
|
1305
2181
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
|
|
@@ -1315,9 +2191,11 @@ export const piHarness: Harness = {
|
|
|
1315
2191
|
slashCommands: piSlashCommands,
|
|
1316
2192
|
// claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
|
|
1317
2193
|
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
1318
|
-
liveness:
|
|
2194
|
+
liveness: socketListenerLiveness,
|
|
2195
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
1319
2196
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1320
|
-
cleanupRuntime: (rec) =>
|
|
2197
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2198
|
+
coldRuntime: async () => ({ ok: true }),
|
|
1321
2199
|
// reopen the SAME conversation: `--session <id>` resumes the exact session we pinned at launch and FAILS
|
|
1322
2200
|
// LOUD when its file is gone (unlike `--session-id`, which would silently mint a fresh empty session).
|
|
1323
2201
|
resumeArg: (rec) => `--session ${rec.session}`,
|
|
@@ -1331,15 +2209,13 @@ export const piHeadlessHarness: Harness = {
|
|
|
1331
2209
|
...piHarness,
|
|
1332
2210
|
id: 'pi-headless',
|
|
1333
2211
|
headless: true,
|
|
1334
|
-
|
|
2212
|
+
runtimeOwnership: 'adapter',
|
|
1335
2213
|
paneTitleIsSelfSummary: false,
|
|
1336
2214
|
launchCmd: (id, runtimeDir, cmd) => piHeadlessLaunchCommand(id, runtimeDir ?? runtimeRoot(), piBaseCmd(cmd)),
|
|
1337
|
-
liveness:
|
|
2215
|
+
liveness: recordOnline,
|
|
1338
2216
|
deliver: deliverViaPiHeadless,
|
|
1339
|
-
cleanupRuntime: (rec) =>
|
|
1340
|
-
|
|
1341
|
-
try { rmSync(rvSock(rec.session), { force: true }) } catch { /* already gone */ }
|
|
1342
|
-
},
|
|
2217
|
+
cleanupRuntime: (rec) => unlinkSocks(piHeadlessSock(rec.session), rvSock(rec.session)),
|
|
2218
|
+
coldRuntime: async () => ({ ok: false, reason: 'pi-headless has no exact resident unload verification' }),
|
|
1343
2219
|
deliveryBlockedBy: undefined,
|
|
1344
2220
|
resumeArg: (rec) => `--session ${rec.session}`,
|
|
1345
2221
|
}
|
|
@@ -1347,7 +2223,6 @@ export const piHeadlessHarness: Harness = {
|
|
|
1347
2223
|
export const opencodeHarness: Harness = {
|
|
1348
2224
|
id: 'opencode',
|
|
1349
2225
|
headless: false,
|
|
1350
|
-
messageStream: false,
|
|
1351
2226
|
events: OPENCODE_EVENTS,
|
|
1352
2227
|
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
1353
2228
|
// socket the launch env hands it and speaks the reply/repaint mini-protocol, so claude's deliver (atomic
|
|
@@ -1362,6 +2237,7 @@ export const opencodeHarness: Harness = {
|
|
|
1362
2237
|
// (no codex-style shared-server contamination). This var is therefore never set; envSessionId's
|
|
1363
2238
|
// SPEXCODE_SESSION_ID tier resolves the record.
|
|
1364
2239
|
sessionEnvVar: 'OPENCODE_SESSION_ID',
|
|
2240
|
+
launchEnv: rendezvousLaunchEnv,
|
|
1365
2241
|
// the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
|
|
1366
2242
|
// by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
|
|
1367
2243
|
shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
|
|
@@ -1379,10 +2255,11 @@ export const opencodeHarness: Harness = {
|
|
|
1379
2255
|
// online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
|
|
1380
2256
|
// (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
|
|
1381
2257
|
// load still reads honestly from the process signal instead of a false offline.
|
|
1382
|
-
liveness:
|
|
1383
|
-
|
|
2258
|
+
liveness: socketListenerOrPidAliveLiveness,
|
|
2259
|
+
leafOwnerNeedle: (rec) => rec.session,
|
|
1384
2260
|
deliver: (rec, text) => deliverViaRendezvous(rec.session, text),
|
|
1385
|
-
cleanupRuntime: (rec) =>
|
|
2261
|
+
cleanupRuntime: (rec) => unlinkSocks(rvSock(rec.session)),
|
|
2262
|
+
coldRuntime: async () => ({ ok: true }),
|
|
1386
2263
|
// owned opencode session id → `--resume <id>` marker (the launch script re-attaches `--session <id>`, the
|
|
1387
2264
|
// SAME conversation); never captured → `--continue` marker (opencode's own "last session in this directory",
|
|
1388
2265
|
// which in a dedicated worktree is this worker's). The discriminator is sound for the same reason codex's
|
|
@@ -1396,21 +2273,19 @@ export const opencodeHeadlessHarness: Harness = {
|
|
|
1396
2273
|
...opencodeHarness,
|
|
1397
2274
|
id: 'opencode-headless',
|
|
1398
2275
|
headless: true,
|
|
1399
|
-
|
|
2276
|
+
runtimeOwnership: 'adapter',
|
|
1400
2277
|
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:
|
|
2278
|
+
// A sleeping native conversation is still addressable by its non-stopped record. Transport breakage belongs
|
|
2279
|
+
// to the next delivery, where the live rendezvous or pane wake reports it loudly.
|
|
2280
|
+
liveness: recordOnline,
|
|
2281
|
+
coldRuntime: async () => ({ ok: false, reason: 'opencode-headless has no exact resident unload verification' }),
|
|
1404
2282
|
deliver: async (rec, text) => {
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
}
|
|
1412
|
-
}
|
|
1413
|
-
return spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session))
|
|
2283
|
+
return deliverViaSocketOrWake(
|
|
2284
|
+
rec.session,
|
|
2285
|
+
text,
|
|
2286
|
+
() => spawnOpenCodeHeadlessTurn(rec, text, opencodeBaseCmd(rec.launchCmd ?? undefined), rvSock(rec.session)),
|
|
2287
|
+
`opencode-headless rendezvous probe was inconclusive for session ${rec.session} - refusing to start a possibly duplicate turn`,
|
|
2288
|
+
)
|
|
1414
2289
|
},
|
|
1415
2290
|
}
|
|
1416
2291
|
|