spexcode 0.5.3 → 0.5.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/spec-cli/hooks/dispatch.sh +10 -0
- package/spec-cli/src/cli.ts +10 -9
- package/spec-cli/src/contract-filter.ts +73 -29
- package/spec-cli/src/guide.ts +2 -1
- package/spec-cli/src/harness.ts +426 -120
- package/spec-cli/src/host-resources.ts +20 -28
- package/spec-cli/src/index.ts +2 -1
- package/spec-cli/src/init.ts +26 -7
- package/spec-cli/src/layout.ts +3 -11
- package/spec-cli/src/materialize.ts +118 -49
- package/spec-cli/src/process-identity.ts +144 -19
- package/spec-cli/src/runtime-ownership.ts +5 -16
- package/spec-cli/src/sessions.ts +123 -17
- package/spec-cli/templates/hooks/pre-commit +3 -9
- package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
package/spec-cli/src/harness.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { piHeadlessLaunchCommand, piHeadlessSock, deliverViaPiHeadless } from '.
|
|
|
16
16
|
import { runtimeRoot, mainCheckout, readConfig, sessionArtifactPath } from './layout.js'
|
|
17
17
|
import { git } from './git.js'
|
|
18
18
|
import { shQuote } from './sh.js'
|
|
19
|
-
import { processStartToken,
|
|
19
|
+
import { detachedRuntimeGenerationToken, processStartToken, verifyDetachedRuntime, type VerifiedDetachedRuntime } from './process-identity.js'
|
|
20
20
|
|
|
21
21
|
// @@@ harness-adapter - the ONE seam between SpexCode and the coding-agent harness (Claude Code, Codex, …).
|
|
22
22
|
// Every harness-specific fact lives behind THIS interface with one implementation per harness; product code
|
|
@@ -37,6 +37,8 @@ export type HarnessLaunchReadinessFence = {
|
|
|
37
37
|
readonly proof: Readonly<Record<string, unknown>>
|
|
38
38
|
validate(current: () => HarnessLaunchReadyRecord | null): Promise<boolean>
|
|
39
39
|
}
|
|
40
|
+
export type TurnFailure = { message: string; completedAt: number | null }
|
|
41
|
+
export type FailureSubscription = { close(): void; readonly closed: Promise<string | null> }
|
|
40
42
|
// the per-pane runtime probe the caller snapshots ONCE for the whole session list and hands liveness():
|
|
41
43
|
// the pane's root pid (tmux `#{pane_pid}`), the hot-tier `pidAlive` verdict, and — ONLY on the legacy path —
|
|
42
44
|
// one whole-box pid→(ppid, comm) table (a single `ps` spawn).
|
|
@@ -53,13 +55,13 @@ export type SharedRuntimeDescriptor = {
|
|
|
53
55
|
key: string
|
|
54
56
|
label: string
|
|
55
57
|
pidFile: string
|
|
56
|
-
|
|
58
|
+
receiptFile: string
|
|
57
59
|
// Lightweight project-wide resident census used by read projections. It must return exact loaded IDs without
|
|
58
60
|
// per-thread reads; the full probe remains the resource/lifecycle surface that also reads turn state.
|
|
59
61
|
residency?: () => Promise<{ healthy: boolean; referenceIds: string[]; error?: string; rootAbsent?: boolean }>
|
|
60
62
|
// Lifecycle mutation guard is deliberately narrower than the full resource projection: census every loaded
|
|
61
63
|
// ID, but read only the exact governed target when it is loaded, plus both target descendant collections.
|
|
62
|
-
mutationGuard?: (targetReferenceId: string) => Promise<SharedRuntimeMutationGuard>
|
|
64
|
+
mutationGuard?: (targetReferenceId: string, opts?: { coldReceipt?: unknown }) => Promise<SharedRuntimeMutationGuard>
|
|
63
65
|
probe(): Promise<SharedRuntimeProbe>
|
|
64
66
|
}
|
|
65
67
|
export type SharedRuntimeMutationGuard = {
|
|
@@ -67,6 +69,7 @@ export type SharedRuntimeMutationGuard = {
|
|
|
67
69
|
referenceIds: string[]
|
|
68
70
|
targetTurnPresence: 'none' | 'idle' | 'active' | 'unknown'
|
|
69
71
|
descendantIds: string[]
|
|
72
|
+
coldTeardownAuthorized?: boolean
|
|
70
73
|
error?: string
|
|
71
74
|
}
|
|
72
75
|
export type SharedRuntimeProbe = {
|
|
@@ -79,6 +82,10 @@ export type SharedRuntimeProbe = {
|
|
|
79
82
|
error?: string
|
|
80
83
|
}
|
|
81
84
|
|
|
85
|
+
export type HarnessColdPreflight =
|
|
86
|
+
| { ok: true; alreadyCold?: boolean; receipt?: unknown }
|
|
87
|
+
| { ok: false; reason: string }
|
|
88
|
+
|
|
82
89
|
export type AdapterLoadedReferenceState = {
|
|
83
90
|
healthy: boolean
|
|
84
91
|
loaded: boolean
|
|
@@ -127,6 +134,8 @@ export async function adapterLoadedReferenceState(
|
|
|
127
134
|
|
|
128
135
|
export interface Harness {
|
|
129
136
|
readonly id: HarnessId
|
|
137
|
+
// the id baked into the materialized shim. Headless variants reuse their native family's shim.
|
|
138
|
+
readonly dispatchId: 'claude' | 'codex' | 'opencode' | 'pi'
|
|
130
139
|
// whether this harness runs without an interactive TUI. The dashboard launcher picker hides headless
|
|
131
140
|
// adapters by default ([[launcher-visibility]]); CLI launcher resolution never consumes that policy.
|
|
132
141
|
readonly headless: boolean
|
|
@@ -187,6 +196,9 @@ export interface Harness {
|
|
|
187
196
|
// --- materialize: shim + contract + trust ([[harness-delivery]]) ---
|
|
188
197
|
// the auto-discovered hook shim file for this harness (.claude/settings.json vs .codex/hooks.json).
|
|
189
198
|
shimFile(proj: string): string
|
|
199
|
+
// whether that shim belongs to one checkout or the whole project. This is adapter placement data: Codex
|
|
200
|
+
// reads one root-checkout hook file for every linked tree; the other harnesses discover their tree-local file.
|
|
201
|
+
shimScope: 'tree' | 'project'
|
|
190
202
|
// a LINKED WORKTREE's extra shim copy — the worktree-side `.codex` hook file that ANCHORS codex's project
|
|
191
203
|
// config layer, or null when the harness needs none. codex-rs only builds a project config layer (and thus
|
|
192
204
|
// only DISCOVERS a worktree thread's hooks) for a dir in [cwd..project_root] that contains a `.codex/`
|
|
@@ -262,6 +274,9 @@ export interface Harness {
|
|
|
262
274
|
// (mid-turn, not queued for after the agent stops) or `turn/start`s a fresh turn when the thread is idle.
|
|
263
275
|
// Returns ok=false with a reason that propagates to the API.
|
|
264
276
|
deliver(rec: HarnessDeliveryRecord, text: string): Promise<DispatchResult>
|
|
277
|
+
// Observe native turn failures that this harness does not expose as a lifecycle hook. The adapter owns the
|
|
278
|
+
// transport subscription; sessions owns observer reconciliation and the active-only lifecycle CAS.
|
|
279
|
+
observeTurnFailures?(rec: HarnessDeliveryRecord, onFailure: (failure: TurnFailure) => void): FailureSubscription
|
|
265
280
|
// Hard-interrupt the current turn through the harness's native control plane. Optional because a harness
|
|
266
281
|
// without a confirmed native interrupt must refuse rather than emulate one with a signal or PTY key.
|
|
267
282
|
interrupt?(rec: HarnessDeliveryRecord): Promise<DispatchResult>
|
|
@@ -273,14 +288,16 @@ export interface Harness {
|
|
|
273
288
|
cleanupRuntime(rec: HarnessLivenessRecord): Promise<void>
|
|
274
289
|
// Archive preflight runs BEFORE any leaf signal. It may inspect shared references to refuse an active or
|
|
275
290
|
// unknown target turn, but it must not mutate the shared runtime; coldRuntime is the sole commit primitive.
|
|
276
|
-
|
|
291
|
+
// Its optional receipt is opaque adapter authority: product code may only pass the same object back to the
|
|
292
|
+
// stop guard and coldRuntime, never inspect it or synthesize a recursive/archive mode.
|
|
293
|
+
coldPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<HarnessColdPreflight>
|
|
277
294
|
// A record that is already archived needs a target-only continuing-cold proof. Unlike mutation preflight,
|
|
278
295
|
// this must not thread/read unrelated loaded siblings merely to retire a target whose runtime is absent.
|
|
279
296
|
coldRetirementPreflight?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }): Promise<{ ok: true; alreadyCold: true } | { ok: false; reason: string }>
|
|
280
297
|
// Optional cold-storage proof/cleanup. A harness with a per-session loaded reference must remove exactly that
|
|
281
298
|
// 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 }>
|
|
299
|
+
coldRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
300
|
+
restoreRuntime?(rec: HarnessLivenessRecord & { harnessSessionId?: string | null }, receipt?: unknown): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
284
301
|
// Project-scoped runtimes are adapter facts. Resource governance consumes these descriptors to report
|
|
285
302
|
// references and protect a sibling-owned control plane without learning harness command names.
|
|
286
303
|
sharedRuntimes?(runtimeDir: string): readonly SharedRuntimeDescriptor[]
|
|
@@ -297,7 +314,7 @@ export interface Harness {
|
|
|
297
314
|
// skill/agent files named in `arts` — never the user's surrounding prose, their other settings, or any .spec
|
|
298
315
|
// data. materialize calls it for every UNSELECTED harness, so dropping a harness from spexcode.json's
|
|
299
316
|
// `harnesses` prunes that harness's products on the next re-materialize.
|
|
300
|
-
clean(proj: string, arts: HarnessArtifacts): void
|
|
317
|
+
clean(proj: string, arts: HarnessArtifacts, preserveProject?: boolean): void
|
|
301
318
|
// the inverse of writeTrust: strip THIS project's spexcode trust block from the harness's global config.
|
|
302
319
|
// Codex removes its `~/.codex/config.toml` block; Claude is a no-op (it wrote none).
|
|
303
320
|
removeTrust(proj: string): void
|
|
@@ -422,37 +439,26 @@ export const codexAppServerSock = (dir = runtimeRoot()) => {
|
|
|
422
439
|
return join(base, `spexcode-cx-${createHash('sha1').update(dir).digest('hex').slice(0, 16)}.sock`)
|
|
423
440
|
}
|
|
424
441
|
export const codexAppServerPid = (dir = runtimeRoot()) => join(dir, 'codex-app-server.pid')
|
|
425
|
-
export const
|
|
442
|
+
export const codexAppServerReceipt = (dir = runtimeRoot()) => join(dir, 'codex-app-server.detached.json')
|
|
426
443
|
type CodexRuntimeGenerationProof = Readonly<{
|
|
427
|
-
|
|
428
|
-
startToken: string
|
|
429
|
-
processGroupId: number
|
|
430
|
-
sessionId: number
|
|
431
|
-
isolation: string
|
|
444
|
+
identity: VerifiedDetachedRuntime
|
|
432
445
|
socket: Readonly<{ path: string; dev: number; ino: number }>
|
|
433
446
|
}>
|
|
434
447
|
function codexRuntimeGenerationProof(dir = runtimeRoot()): CodexRuntimeGenerationProof | null {
|
|
435
448
|
try {
|
|
436
449
|
const pid = Number(readFileSync(codexAppServerPid(dir), 'utf8').trim())
|
|
437
|
-
const
|
|
438
|
-
const scope = readFileSync(codexAppServerIsolation(dir), 'utf8').trim()
|
|
439
|
-
const topology = processTopology(pid)
|
|
450
|
+
const detached = verifyDetachedRuntime(pid, codexAppServerReceipt(dir))
|
|
440
451
|
const socketPath = codexAppServerSock(dir)
|
|
441
452
|
const socket = statSync(socketPath)
|
|
442
|
-
if (!(pid > 0) || !
|
|
443
|
-
scope !== `detached-v3 ${pid} ${start} ${pid} ${pid}` || !socket.isSocket()) return null
|
|
453
|
+
if (!(pid > 0) || !detached.ok || !socket.isSocket()) return null
|
|
444
454
|
return Object.freeze({
|
|
445
|
-
|
|
446
|
-
startToken: start,
|
|
447
|
-
processGroupId: topology.processGroupId,
|
|
448
|
-
sessionId: topology.sessionId,
|
|
449
|
-
isolation: scope,
|
|
455
|
+
identity: detached.identity,
|
|
450
456
|
socket: Object.freeze({ path: socketPath, dev: socket.dev, ino: socket.ino }),
|
|
451
457
|
})
|
|
452
458
|
} catch { return null }
|
|
453
459
|
}
|
|
454
460
|
const codexRuntimeGenerationToken = (proof: CodexRuntimeGenerationProof) =>
|
|
455
|
-
`${proof.
|
|
461
|
+
`${detachedRuntimeGenerationToken(proof.identity)}|${proof.socket.path}|${proof.socket.dev}:${proof.socket.ino}`
|
|
456
462
|
function codexRuntimeGeneration(dir = runtimeRoot()): string | null {
|
|
457
463
|
const proof = codexRuntimeGenerationProof(dir)
|
|
458
464
|
return proof ? codexRuntimeGenerationToken(proof) : null
|
|
@@ -673,14 +679,14 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
|
|
|
673
679
|
const tuiBypass = !codexCmd.includes('--dangerously-bypass-hook-trust') && codexSupportsBypassHookTrust(codexBinary(codexCmd)) ? ' --dangerously-bypass-hook-trust' : ''
|
|
674
680
|
const sock = codexAppServerSock(dir) // short sun_path-safe path in the owned tmp subdir/override — NOT under "$dir"
|
|
675
681
|
const pid = codexAppServerPid(dir)
|
|
676
|
-
const
|
|
682
|
+
const receipt = codexAppServerReceipt(dir)
|
|
677
683
|
const log = join(dir, 'codex-app-server.log')
|
|
678
684
|
const lock = join(dir, 'codex-app-server.lock')
|
|
679
685
|
const script = [
|
|
680
686
|
`dir=${shQuote(dir)}`,
|
|
681
687
|
`sock=${shQuote(sock)}`,
|
|
682
688
|
`pid=${shQuote(pid)}`,
|
|
683
|
-
`
|
|
689
|
+
`receipt=${shQuote(receipt)}`,
|
|
684
690
|
`log=${shQuote(log)}`,
|
|
685
691
|
`lock=${shQuote(lock)}`,
|
|
686
692
|
// codex-launch's bypass-trust gate (and writeTrust's) resolves the codex binary from SPEXCODE_CODEX_CMD;
|
|
@@ -724,10 +730,10 @@ export function codexLaunchCommand(id: string, codexCmd = 'codex', serverCmd?: s
|
|
|
724
730
|
// mis-attributes; the id it needs — the ACTING thread's — codex injects per command, so stripping the
|
|
725
731
|
// inherited ones removes a wrong answer without removing a right one ([[harness-adapter]]).
|
|
726
732
|
// 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
|
|
728
|
-
//
|
|
729
|
-
//
|
|
730
|
-
` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$
|
|
733
|
+
// a boundary: the Codex Node launcher reset signal handling and died with the tmux pane. The internal helper
|
|
734
|
+
// uses child_process detached=true, then the process adapter publishes a private receipt only after proving
|
|
735
|
+
// exact PID/start + PGID (and Linux SID). Every consumer re-verifies that same receipt.
|
|
736
|
+
` ( unset ${sessionIdentityEnvVars().join(' ')}; ${SPEX} internal shared-runtime-spawn "$dir" "$log" "$pid" "$receipt" ${server} app-server --listen "unix://$sock" ) || { rmdir "$lockd" 2>/dev/null; exit 1; }`,
|
|
731
737
|
' for i in $(seq 1 100); do [ -S "$sock" ] && break; sleep 0.05; done',
|
|
732
738
|
'fi',
|
|
733
739
|
'rmdir "$lockd" 2>/dev/null',
|
|
@@ -820,6 +826,106 @@ function drainWsFrames(s: FrameState, conn: Socket, onText: (json: string) => vo
|
|
|
820
826
|
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`
|
|
821
827
|
const wsInitialize: JsonRpc = { id: 1, method: 'initialize', params: { clientInfo: { name: 'spexcode', title: 'SpexCode', version: '0.0.0' }, capabilities: { experimentalApi: true, requestAttestation: false } } }
|
|
822
828
|
|
|
829
|
+
// Codex has no StopFailure hook, but its app-server has the stronger native signal: every subscribed turn ends
|
|
830
|
+
// with turn/completed and a final completed/interrupted/failed status. Rejoin is atomic with subscription, so
|
|
831
|
+
// this observer also survives backend replacement; a thread already in systemError is reconciled from its
|
|
832
|
+
// latest turn before later live notifications take over.
|
|
833
|
+
export function codexTurnFailureObserver(
|
|
834
|
+
rec: HarnessDeliveryRecord,
|
|
835
|
+
onFailure: (failure: TurnFailure) => void,
|
|
836
|
+
): FailureSubscription {
|
|
837
|
+
const threadId = rec.harnessSessionId
|
|
838
|
+
if (!threadId) return { close: () => {}, closed: Promise.resolve(null) }
|
|
839
|
+
const sock = codexAppServerSock(rec.runtimeDir || runtimeRoot())
|
|
840
|
+
const conn: Socket = createConnection(sock)
|
|
841
|
+
const frames: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
842
|
+
let upgraded = false, settled = false
|
|
843
|
+
let reconciliationTimer: ReturnType<typeof setTimeout> | null = null
|
|
844
|
+
let resolveClosed!: (reason: string | null) => void
|
|
845
|
+
const closed = new Promise<string | null>((resolve) => { resolveClosed = resolve })
|
|
846
|
+
const cancelReconciliation = () => {
|
|
847
|
+
if (!reconciliationTimer) return
|
|
848
|
+
clearTimeout(reconciliationTimer)
|
|
849
|
+
reconciliationTimer = null
|
|
850
|
+
}
|
|
851
|
+
const finish = (reason: string | null) => {
|
|
852
|
+
if (settled) return
|
|
853
|
+
settled = true
|
|
854
|
+
clearTimeout(timer)
|
|
855
|
+
cancelReconciliation()
|
|
856
|
+
try { conn.destroy() } catch {}
|
|
857
|
+
resolveClosed(reason)
|
|
858
|
+
}
|
|
859
|
+
const timer = setTimeout(() => finish('Codex turn observer did not subscribe within 5000ms'), 5000)
|
|
860
|
+
timer.unref?.()
|
|
861
|
+
const send = (message: JsonRpc) => conn.write(wsText(JSON.stringify(message)))
|
|
862
|
+
const report = (turn: unknown, fallbackMessage?: string) => {
|
|
863
|
+
const value = turn as { status?: unknown; completedAt?: unknown; error?: { message?: unknown } | null }
|
|
864
|
+
if (value?.status !== 'failed' && !fallbackMessage) return
|
|
865
|
+
const nativeMessage = typeof value?.error?.message === 'string' ? value.error.message.trim() : ''
|
|
866
|
+
onFailure({
|
|
867
|
+
message: nativeMessage || fallbackMessage || 'Codex turn failed',
|
|
868
|
+
completedAt: typeof value?.completedAt === 'number' && Number.isFinite(value.completedAt) ? value.completedAt : null,
|
|
869
|
+
})
|
|
870
|
+
}
|
|
871
|
+
conn.on('error', (error) => finish(`Codex turn observer connection failed: ${rpcError(error)}`))
|
|
872
|
+
conn.on('close', () => finish('Codex turn observer connection closed'))
|
|
873
|
+
conn.on('connect', () => conn.write(WS_UPGRADE(randomBytes(16).toString('base64'))))
|
|
874
|
+
const handle = (json: string) => {
|
|
875
|
+
let message: JsonRpc
|
|
876
|
+
try { message = JSON.parse(json) } catch { return }
|
|
877
|
+
if (message.error) return finish(`Codex turn observer request failed: ${message.error.message || JSON.stringify(message.error)}`)
|
|
878
|
+
if (message.id === 1 && message.result) {
|
|
879
|
+
send({ method: 'initialized', params: {} })
|
|
880
|
+
return send({
|
|
881
|
+
id: 2,
|
|
882
|
+
method: 'thread/resume',
|
|
883
|
+
params: { threadId, excludeTurns: true, initialTurnsPage: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } },
|
|
884
|
+
})
|
|
885
|
+
}
|
|
886
|
+
if (message.id === 2 && message.result) {
|
|
887
|
+
clearTimeout(timer)
|
|
888
|
+
const result = message.result as { thread?: { status?: { type?: unknown } }; initialTurnsPage?: { data?: unknown } }
|
|
889
|
+
if (result.thread?.status?.type === 'systemError') {
|
|
890
|
+
const turns = result.initialTurnsPage?.data
|
|
891
|
+
const latest = Array.isArray(turns) ? turns[0] : null
|
|
892
|
+
// Give a concurrently-starting turn's native notification precedence over this historical snapshot.
|
|
893
|
+
reconciliationTimer = setTimeout(() => {
|
|
894
|
+
reconciliationTimer = null
|
|
895
|
+
report(latest, 'Codex thread entered systemError before the turn observer subscribed')
|
|
896
|
+
}, 100)
|
|
897
|
+
reconciliationTimer.unref?.()
|
|
898
|
+
}
|
|
899
|
+
return
|
|
900
|
+
}
|
|
901
|
+
if (message.method === 'turn/started') {
|
|
902
|
+
const params = message.params as { threadId?: unknown } | undefined
|
|
903
|
+
if (params?.threadId === threadId) cancelReconciliation()
|
|
904
|
+
}
|
|
905
|
+
if (message.method === 'turn/completed') {
|
|
906
|
+
const params = message.params as { threadId?: unknown; turn?: unknown } | undefined
|
|
907
|
+
if (params?.threadId === threadId) {
|
|
908
|
+
cancelReconciliation()
|
|
909
|
+
report(params.turn)
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
conn.on('data', (chunk: Buffer) => {
|
|
914
|
+
frames.buf = Buffer.concat([frames.buf, chunk])
|
|
915
|
+
if (!upgraded) {
|
|
916
|
+
const split = frames.buf.indexOf('\r\n\r\n')
|
|
917
|
+
if (split < 0) return
|
|
918
|
+
const head = frames.buf.slice(0, split).toString('utf8')
|
|
919
|
+
if (!/^HTTP\/1\.1 101/.test(head)) return finish(`Codex app-server refused turn observer: ${head.split('\r\n')[0]}`)
|
|
920
|
+
upgraded = true
|
|
921
|
+
frames.buf = frames.buf.slice(split + 4)
|
|
922
|
+
send(wsInitialize)
|
|
923
|
+
}
|
|
924
|
+
if (drainWsFrames(frames, conn, handle)) finish('Codex app-server closed the turn observer')
|
|
925
|
+
})
|
|
926
|
+
return { close: () => finish(null), closed }
|
|
927
|
+
}
|
|
928
|
+
|
|
823
929
|
// Protocol-verified cold/restore seam. The Codex schema (`codex app-server generate-json-schema --experimental`)
|
|
824
930
|
// defines thread/archive and thread/unarchive with {threadId}; no guessed method or process command is used.
|
|
825
931
|
type CodexGenerationFence = { dir: string; generation: string }
|
|
@@ -890,7 +996,14 @@ export const CODEX_THREAD_SOURCE_KINDS = [
|
|
|
890
996
|
'cli', 'vscode', 'exec', 'appServer', 'subAgent', 'subAgentReview', 'subAgentCompact',
|
|
891
997
|
'subAgentThreadSpawn', 'subAgentOther', 'unknown',
|
|
892
998
|
] as const
|
|
893
|
-
function codexPagedIds(
|
|
999
|
+
function codexPagedIds(
|
|
1000
|
+
sock: string,
|
|
1001
|
+
method: 'thread/list' | 'thread/loaded/list',
|
|
1002
|
+
params: Record<string, unknown>,
|
|
1003
|
+
extractId: (item: unknown) => string | null,
|
|
1004
|
+
label: string,
|
|
1005
|
+
onItem?: (item: unknown) => void,
|
|
1006
|
+
): Promise<CodexPagedIdsResult> {
|
|
894
1007
|
return new Promise((resolve) => {
|
|
895
1008
|
const conn: Socket = createConnection(sock)
|
|
896
1009
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
@@ -914,6 +1027,7 @@ function codexPagedIds(sock: string, method: 'thread/list' | 'thread/loaded/list
|
|
|
914
1027
|
if (message.id !== requestId || !message.result) return
|
|
915
1028
|
const page = message.result as { data?: unknown; nextCursor?: unknown }
|
|
916
1029
|
if (Array.isArray(page.data)) for (const item of page.data) {
|
|
1030
|
+
onItem?.(item)
|
|
917
1031
|
const id = extractId(item)
|
|
918
1032
|
if (typeof id === 'string') ids.add(id)
|
|
919
1033
|
}
|
|
@@ -992,14 +1106,35 @@ function codexTargetTurnPresence(sock: string, threadId: string): Promise<{ ok:
|
|
|
992
1106
|
// The app-server's loaded/list is cursor-paginated. Archive proof must scan every page; a first page that omits
|
|
993
1107
|
// a sibling/descendant is not a cold proof. This helper is also used by the descendant guard below.
|
|
994
1108
|
export function codexThreadList(sock: string, params: Record<string, unknown>): Promise<{ ok: true; ids: string[] } | { ok: false; error: string }> {
|
|
1109
|
+
return codexThreadCollection(sock, params).then((result) => result.ok ? { ok: true, ids: result.ids } : result)
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
type CodexThreadCollectionResult =
|
|
1113
|
+
| { ok: true; ids: string[]; parentById: Map<string, string | null> }
|
|
1114
|
+
| { ok: false; error: string }
|
|
1115
|
+
|
|
1116
|
+
function codexThreadCollection(sock: string, params: Record<string, unknown>): Promise<CodexThreadCollectionResult> {
|
|
995
1117
|
const sourceKinds = Array.isArray(params.sourceKinds) && params.sourceKinds.length
|
|
996
1118
|
? params.sourceKinds
|
|
997
1119
|
: [...CODEX_THREAD_SOURCE_KINDS]
|
|
1120
|
+
const parentById = new Map<string, string | null>()
|
|
1121
|
+
const conflictingParents = new Set<string>()
|
|
998
1122
|
return codexPagedIds(sock, 'thread/list', { ...params, sourceKinds, useStateDbOnly: true }, (item) => {
|
|
999
1123
|
if (typeof item === 'string') return item
|
|
1000
1124
|
const id = (item as { id?: unknown } | null)?.id
|
|
1001
1125
|
return typeof id === 'string' ? id : null
|
|
1002
|
-
}, 'thread/list')
|
|
1126
|
+
}, 'thread/list', (item) => {
|
|
1127
|
+
if (!item || typeof item !== 'object') return
|
|
1128
|
+
const row = item as { id?: unknown; parentThreadId?: unknown }
|
|
1129
|
+
if (typeof row.id !== 'string') return
|
|
1130
|
+
const parent = typeof row.parentThreadId === 'string' ? row.parentThreadId : null
|
|
1131
|
+
if (parentById.has(row.id) && parentById.get(row.id) !== parent) conflictingParents.add(row.id)
|
|
1132
|
+
parentById.set(row.id, parent)
|
|
1133
|
+
}).then((result) => {
|
|
1134
|
+
if (!result.ok) return result
|
|
1135
|
+
if (conflictingParents.size) return { ok: false as const, error: `Codex thread/list returned conflicting parent ownership for ${[...conflictingParents].join(', ')}` }
|
|
1136
|
+
return { ...result, parentById }
|
|
1137
|
+
})
|
|
1003
1138
|
}
|
|
1004
1139
|
|
|
1005
1140
|
async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()): Promise<SharedRuntimeMutationGuard> {
|
|
@@ -1029,28 +1164,194 @@ async function codexTargetMutationGuard(threadId: string, dir = runtimeRoot()):
|
|
|
1029
1164
|
return { healthy: true, referenceIds, targetTurnPresence, descendantIds }
|
|
1030
1165
|
}
|
|
1031
1166
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1167
|
+
const CODEX_COLD_PLAN = Symbol('codex-cold-plan')
|
|
1168
|
+
type CodexColdPlan = Readonly<{
|
|
1169
|
+
[CODEX_COLD_PLAN]: true
|
|
1170
|
+
kind: 'codex-cold-subtree-v1'
|
|
1171
|
+
threadId: string
|
|
1172
|
+
generation: string
|
|
1173
|
+
guard: SharedRuntimeMutationGuard
|
|
1174
|
+
descendantIds: readonly string[]
|
|
1175
|
+
parentEdges: readonly (readonly [string, string])[]
|
|
1176
|
+
subtreeIds: readonly string[]
|
|
1177
|
+
activeIds: readonly string[]
|
|
1178
|
+
archivedIds: readonly string[]
|
|
1179
|
+
}>
|
|
1180
|
+
type CodexColdPreflight = { ok: true; alreadyCold?: boolean; receipt: CodexColdPlan } | { ok: false; reason: string }
|
|
1181
|
+
|
|
1182
|
+
const sameIdSet = (left: readonly string[], right: readonly string[]) =>
|
|
1183
|
+
left.length === right.length && left.every((id) => right.includes(id))
|
|
1184
|
+
|
|
1185
|
+
const sameParentEdges = (left: readonly (readonly [string, string])[], right: readonly (readonly [string, string])[]) =>
|
|
1186
|
+
left.length === right.length && left.every(([id, parent]) => right.some(([otherId, otherParent]) => id === otherId && parent === otherParent))
|
|
1187
|
+
|
|
1188
|
+
const isCodexColdPlan = (value: unknown): value is CodexColdPlan => {
|
|
1189
|
+
if (!value || typeof value !== 'object') return false
|
|
1190
|
+
const plan = value as Partial<CodexColdPlan>
|
|
1191
|
+
return plan[CODEX_COLD_PLAN] === true && plan.kind === 'codex-cold-subtree-v1' && typeof plan.threadId === 'string' &&
|
|
1192
|
+
typeof plan.generation === 'string' && Array.isArray(plan.descendantIds) &&
|
|
1193
|
+
Array.isArray(plan.parentEdges) && Array.isArray(plan.subtreeIds) &&
|
|
1194
|
+
Array.isArray(plan.activeIds) && Array.isArray(plan.archivedIds) && !!plan.guard
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
async function codexColdPreflight(threadId: string, dir = runtimeRoot(), expectedGeneration?: string): Promise<CodexColdPreflight> {
|
|
1198
|
+
const generation = expectedGeneration ?? codexRuntimeGeneration(dir)
|
|
1199
|
+
if (!generation || codexRuntimeGeneration(dir) !== generation)
|
|
1200
|
+
return { ok: false, reason: 'Codex shared app-server generation is unproven or changed before subtree census' }
|
|
1034
1201
|
const sock = codexAppServerSock(dir)
|
|
1035
|
-
const [
|
|
1036
|
-
|
|
1202
|
+
const [loaded, activeDescendants, archivedDescendants, archivedList, activeList] = await Promise.all([
|
|
1203
|
+
codexLoadedReferenceIds(sock),
|
|
1204
|
+
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: false, sourceKinds: [] }),
|
|
1205
|
+
codexThreadCollection(sock, { ancestorThreadId: threadId, archived: true, sourceKinds: [] }),
|
|
1037
1206
|
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1038
1207
|
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1039
1208
|
])
|
|
1040
|
-
if (
|
|
1041
|
-
return { ok: false, reason:
|
|
1042
|
-
if (!
|
|
1209
|
+
if (codexRuntimeGeneration(dir) !== generation)
|
|
1210
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during subtree census' }
|
|
1211
|
+
if (!loaded.ok) return { ok: false, reason: loaded.error }
|
|
1212
|
+
if (!activeDescendants.ok) return { ok: false, reason: activeDescendants.error }
|
|
1213
|
+
if (!archivedDescendants.ok) return { ok: false, reason: archivedDescendants.error }
|
|
1043
1214
|
if (!archivedList.ok) return { ok: false, reason: archivedList.error }
|
|
1044
1215
|
if (!activeList.ok) return { ok: false, reason: activeList.error }
|
|
1045
|
-
|
|
1046
|
-
const
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
if (
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
if (
|
|
1053
|
-
|
|
1216
|
+
|
|
1217
|
+
const activeDescendantSet = new Set(activeDescendants.ids)
|
|
1218
|
+
const archivedDescendantSet = new Set(archivedDescendants.ids)
|
|
1219
|
+
const duplicateDescendants = activeDescendants.ids.filter((id) => archivedDescendantSet.has(id))
|
|
1220
|
+
if (duplicateDescendants.length)
|
|
1221
|
+
return { ok: false, reason: `Codex subtree members occur in both active and archived descendant collections (${duplicateDescendants.join(', ')})` }
|
|
1222
|
+
const descendantIds = [...activeDescendants.ids, ...archivedDescendants.ids]
|
|
1223
|
+
if (descendantIds.includes(threadId)) return { ok: false, reason: `Codex target ${threadId} is duplicated in its own descendant closure` }
|
|
1224
|
+
|
|
1225
|
+
const parentById = new Map([...activeDescendants.parentById, ...archivedDescendants.parentById])
|
|
1226
|
+
const depthById = new Map<string, number>()
|
|
1227
|
+
for (const id of descendantIds) {
|
|
1228
|
+
const seen = new Set([id])
|
|
1229
|
+
let cursor = id
|
|
1230
|
+
let depth = 0
|
|
1231
|
+
while (cursor !== threadId) {
|
|
1232
|
+
const next = parentById.get(cursor)
|
|
1233
|
+
if (!next) return { ok: false, reason: `Codex descendant ${id} has no complete parent chain to target ${threadId} (unowned or reassigned)` }
|
|
1234
|
+
if (seen.has(next)) return { ok: false, reason: `Codex descendant ${id} has a cyclic parent chain` }
|
|
1235
|
+
seen.add(next)
|
|
1236
|
+
cursor = next
|
|
1237
|
+
depth++
|
|
1238
|
+
}
|
|
1239
|
+
depthById.set(id, depth)
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const activeSet = new Set(activeList.ids)
|
|
1243
|
+
const archivedSet = new Set(archivedList.ids)
|
|
1244
|
+
const subtreeIds = [...descendantIds, threadId]
|
|
1245
|
+
for (const id of subtreeIds) {
|
|
1246
|
+
const inActive = activeSet.has(id)
|
|
1247
|
+
const inArchived = archivedSet.has(id)
|
|
1248
|
+
if (!inActive && !inArchived)
|
|
1249
|
+
return { ok: false, reason: `Codex subtree member ${id} is absent from both native collections (unowned or reassigned)` }
|
|
1250
|
+
if (inActive && inArchived)
|
|
1251
|
+
return { ok: false, reason: `Codex subtree member ${id} occurs in both active and archived native collections` }
|
|
1252
|
+
if (id !== threadId) {
|
|
1253
|
+
const expectedActive = activeDescendantSet.has(id)
|
|
1254
|
+
if (inActive !== expectedActive)
|
|
1255
|
+
return { ok: false, reason: `Codex subtree member ${id} changed collection assignment during ownership census` }
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
const loadedSet = new Set(loaded.referenceIds)
|
|
1260
|
+
const loadedSubtreeIds = subtreeIds.filter((id) => loadedSet.has(id))
|
|
1261
|
+
const turnStates = await Promise.all(loadedSubtreeIds.map(async (id) => ({ id, state: await codexTargetTurnPresence(sock, id) })))
|
|
1262
|
+
if (codexRuntimeGeneration(dir) !== generation)
|
|
1263
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during subtree turn census' }
|
|
1264
|
+
for (const { id, state } of turnStates) {
|
|
1265
|
+
if (!state.ok) return { ok: false, reason: state.error }
|
|
1266
|
+
if (state.turnPresence === 'active') return { ok: false, reason: `Codex subtree member ${id} has an active turn` }
|
|
1267
|
+
if (state.turnPresence === 'unknown') return { ok: false, reason: `Codex subtree member ${id} turn state is unknown` }
|
|
1268
|
+
if (archivedSet.has(id)) return { ok: false, reason: `Codex archived subtree member ${id} remains loaded` }
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const targetTurnPresence: SharedRuntimeMutationGuard['targetTurnPresence'] = loadedSet.has(threadId) ? 'idle' : 'none'
|
|
1272
|
+
const guard: SharedRuntimeMutationGuard = {
|
|
1273
|
+
healthy: true,
|
|
1274
|
+
referenceIds: [...loaded.referenceIds],
|
|
1275
|
+
targetTurnPresence,
|
|
1276
|
+
descendantIds: [...descendantIds],
|
|
1277
|
+
}
|
|
1278
|
+
const activeIds = [...activeDescendants.ids]
|
|
1279
|
+
.sort((left, right) => (depthById.get(right) ?? 0) - (depthById.get(left) ?? 0))
|
|
1280
|
+
.concat(activeSet.has(threadId) ? [threadId] : [])
|
|
1281
|
+
const archivedIds = [...archivedDescendants.ids, ...(archivedSet.has(threadId) ? [threadId] : [])]
|
|
1282
|
+
const parentEdges = descendantIds.map((id) => [id, parentById.get(id)!] as const)
|
|
1283
|
+
const receipt: CodexColdPlan = Object.freeze({
|
|
1284
|
+
[CODEX_COLD_PLAN]: true as const,
|
|
1285
|
+
kind: 'codex-cold-subtree-v1',
|
|
1286
|
+
threadId,
|
|
1287
|
+
generation,
|
|
1288
|
+
guard,
|
|
1289
|
+
descendantIds: Object.freeze([...descendantIds]),
|
|
1290
|
+
parentEdges: Object.freeze(parentEdges),
|
|
1291
|
+
subtreeIds: Object.freeze([...subtreeIds]),
|
|
1292
|
+
activeIds: Object.freeze(activeIds),
|
|
1293
|
+
archivedIds: Object.freeze(archivedIds),
|
|
1294
|
+
})
|
|
1295
|
+
return { ok: true, ...(activeIds.length ? {} : { alreadyCold: true }), receipt }
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
async function codexMutationGuard(
|
|
1299
|
+
threadId: string,
|
|
1300
|
+
dir = runtimeRoot(),
|
|
1301
|
+
opts: { coldReceipt?: unknown } = {},
|
|
1302
|
+
): Promise<SharedRuntimeMutationGuard> {
|
|
1303
|
+
if (opts.coldReceipt === undefined) return codexTargetMutationGuard(threadId, dir)
|
|
1304
|
+
if (!isCodexColdPlan(opts.coldReceipt) || opts.coldReceipt.threadId !== threadId)
|
|
1305
|
+
return { healthy: false, referenceIds: [], targetTurnPresence: 'unknown', descendantIds: [], error: 'adapter cold teardown receipt is invalid' }
|
|
1306
|
+
const current = await codexColdPreflight(threadId, dir, opts.coldReceipt.generation)
|
|
1307
|
+
if (!current.ok) {
|
|
1308
|
+
const guard = await codexTargetMutationGuard(threadId, dir)
|
|
1309
|
+
return { ...guard, healthy: false, coldTeardownAuthorized: false, error: current.reason }
|
|
1310
|
+
}
|
|
1311
|
+
const authorized = sameIdSet(opts.coldReceipt.descendantIds, current.receipt.descendantIds) &&
|
|
1312
|
+
sameParentEdges(opts.coldReceipt.parentEdges, current.receipt.parentEdges) &&
|
|
1313
|
+
sameIdSet(opts.coldReceipt.activeIds, current.receipt.activeIds) &&
|
|
1314
|
+
sameIdSet(opts.coldReceipt.archivedIds, current.receipt.archivedIds)
|
|
1315
|
+
return {
|
|
1316
|
+
...current.receipt.guard,
|
|
1317
|
+
healthy: authorized,
|
|
1318
|
+
coldTeardownAuthorized: authorized,
|
|
1319
|
+
...(authorized ? {} : { error: 'adapter cold teardown receipt no longer matches the target subtree' }),
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
async function codexRestoreColdPlan(plan: CodexColdPlan, dir = runtimeRoot()): Promise<{ ok: true } | { ok: false; reason: string }> {
|
|
1324
|
+
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
1325
|
+
return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
|
|
1326
|
+
const sock = codexAppServerSock(dir)
|
|
1327
|
+
const [activeBefore, archivedBefore] = await Promise.all([
|
|
1328
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1329
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1330
|
+
])
|
|
1331
|
+
if (!activeBefore.ok || !archivedBefore.ok)
|
|
1332
|
+
return { ok: false, reason: 'archive state is unknown and could not be reconciled' }
|
|
1333
|
+
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
1334
|
+
return { ok: false, reason: 'shared Codex app-server generation changed, so no compensation was attempted' }
|
|
1335
|
+
const activeSet = new Set(activeBefore.ids)
|
|
1336
|
+
const archivedSet = new Set(archivedBefore.ids)
|
|
1337
|
+
if (plan.archivedIds.some((id) => !archivedSet.has(id) || activeSet.has(id)))
|
|
1338
|
+
return { ok: false, reason: 'an originally-archived Codex subtree member changed collection; compensation was not authorized' }
|
|
1339
|
+
if (plan.activeIds.some((id) => activeSet.has(id) === archivedSet.has(id)))
|
|
1340
|
+
return { ok: false, reason: 'an originally-active Codex subtree member has ambiguous collection state' }
|
|
1341
|
+
const fence = { dir, generation: plan.generation }
|
|
1342
|
+
const restoreIds = [...plan.activeIds].reverse().filter((id) => archivedSet.has(id))
|
|
1343
|
+
for (const id of restoreIds) {
|
|
1344
|
+
const restored = await codexThreadMutation(sock, 'thread/unarchive', id, fence)
|
|
1345
|
+
if (!restored.ok) return { ok: false, reason: `compensation failed for ${id}: ${restored.error}` }
|
|
1346
|
+
}
|
|
1347
|
+
const [activeAfter, archivedAfter] = await Promise.all([
|
|
1348
|
+
codexThreadList(sock, { archived: false, sourceKinds: [] }),
|
|
1349
|
+
codexThreadList(sock, { archived: true, sourceKinds: [] }),
|
|
1350
|
+
])
|
|
1351
|
+
const restored = activeAfter.ok && archivedAfter.ok && codexRuntimeGeneration(dir) === plan.generation &&
|
|
1352
|
+
plan.activeIds.every((id) => activeAfter.ids.includes(id) && !archivedAfter.ids.includes(id)) &&
|
|
1353
|
+
plan.archivedIds.every((id) => archivedAfter.ids.includes(id) && !activeAfter.ids.includes(id))
|
|
1354
|
+
return restored ? { ok: true } : { ok: false, reason: 'compensation failed or archive state is unknown' }
|
|
1054
1355
|
}
|
|
1055
1356
|
|
|
1056
1357
|
// Read a loaded thread id off the app-server via `thread/loaded/list`. With the backend now OWNING the thread
|
|
@@ -1116,6 +1417,8 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRunt
|
|
|
1116
1417
|
const listener = await listenerAt(sock, 800)
|
|
1117
1418
|
if (!pidLive && listener === 'dead') return { healthy: true, references: [] }
|
|
1118
1419
|
if (!pidLive || listener !== 'live') return { healthy: false, references: [], error: 'Codex shared root state is unknown (PID/listener identity is not proven)' }
|
|
1420
|
+
const generation = codexRuntimeGeneration(dir)
|
|
1421
|
+
if (!generation) return { healthy: false, references: [], error: 'Codex shared root detached receipt/socket generation is not proven' }
|
|
1119
1422
|
return new Promise<SharedRuntimeProbe>((resolve) => {
|
|
1120
1423
|
const conn: Socket = createConnection(sock)
|
|
1121
1424
|
const fs: FrameState = { buf: Buffer.alloc(0), fragOp: 0, fragBuf: Buffer.alloc(0) }
|
|
@@ -1133,7 +1436,9 @@ export function codexSharedRuntimeProbe(dir = runtimeRoot()): Promise<SharedRunt
|
|
|
1133
1436
|
settled = true
|
|
1134
1437
|
clearTimeout(timer)
|
|
1135
1438
|
try { conn.destroy() } catch { /* */ }
|
|
1136
|
-
resolve(result)
|
|
1439
|
+
resolve(result.healthy && codexRuntimeGeneration(dir) !== generation
|
|
1440
|
+
? { healthy: false, references: result.references, error: 'Codex shared root detached receipt/socket generation changed during ownership probe' }
|
|
1441
|
+
: result)
|
|
1137
1442
|
}
|
|
1138
1443
|
const fail = (error: string) => done({ healthy: false, references: [...references.values()], error })
|
|
1139
1444
|
timer = setTimeout(() => fail('codex app-server ownership probe timed out after 5000ms'), 5000)
|
|
@@ -1592,16 +1897,16 @@ function isTrackedFile(proj: string, f: string): boolean {
|
|
|
1592
1897
|
// block; the skill/agent files sit at name-scoped paths reconstructed from `arts`. So it removes ONLY our own
|
|
1593
1898
|
// blocks and our own named products — never a user's CLAUDE.md/AGENTS.md prose, a hand-made settings.json, or
|
|
1594
1899
|
// a sibling skill/agent the user added, and NEVER any .spec data.
|
|
1595
|
-
function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts): void {
|
|
1900
|
+
function cleanHarness(h: Harness, proj: string, arts: HarnessArtifacts, preserveProject = false): void {
|
|
1596
1901
|
// deleteIfEmpty ONLY for an UNTRACKED contract file: a wholly-ours generated file goes; a HOST-TRACKED file
|
|
1597
1902
|
// that carried nothing but our block (an empty committed CLAUDE.md we folded into) is stripped back to its
|
|
1598
1903
|
// pristine emptiness but never deleted — deleting a tracked file would surface as a `D` in the host's status.
|
|
1599
1904
|
for (const f of h.contractFiles(proj)) removeManagedBlock(f, ['<!-- ', ' -->'], !isTrackedFile(proj, f))
|
|
1600
1905
|
const shim = h.shimFile(proj)
|
|
1601
|
-
if (existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
|
|
1906
|
+
if ((h.shimScope === 'tree' || !preserveProject) && existsSync(shim) && readFileSync(shim, 'utf8').includes('dispatch.sh')) rmSync(shim, { force: true })
|
|
1602
1907
|
const anchor = h.worktreeHookAnchor(proj) // the linked-worktree anchor copy, same identity gate as the shim
|
|
1603
1908
|
if (anchor && existsSync(anchor) && readFileSync(anchor, 'utf8').includes('dispatch.sh')) rmSync(anchor, { force: true })
|
|
1604
|
-
h.removeTrust(proj)
|
|
1909
|
+
if (!preserveProject) h.removeTrust(proj)
|
|
1605
1910
|
const sd = h.skillDir(proj)
|
|
1606
1911
|
if (sd) for (const n of arts.skills) rmSync(join(sd, n), { recursive: true, force: true })
|
|
1607
1912
|
const ad = h.agentDir(proj)
|
|
@@ -1758,6 +2063,7 @@ const noLaunchEnv = (): string[] => []
|
|
|
1758
2063
|
|
|
1759
2064
|
export const claudeHarness: Harness = {
|
|
1760
2065
|
id: 'claude',
|
|
2066
|
+
dispatchId: 'claude',
|
|
1761
2067
|
headless: false,
|
|
1762
2068
|
events: CLAUDE_EVENTS,
|
|
1763
2069
|
ownsRendezvous: true, // reclaude opens the rendezvous control socket (prompt delivery + liveness)
|
|
@@ -1768,6 +2074,7 @@ export const claudeHarness: Harness = {
|
|
|
1768
2074
|
sessionEnvVar: 'CLAUDE_CODE_SESSION_ID',
|
|
1769
2075
|
launchEnv: rendezvousLaunchEnv,
|
|
1770
2076
|
shimFile: (proj) => join(proj, '.claude', 'settings.json'),
|
|
2077
|
+
shimScope: 'tree',
|
|
1771
2078
|
worktreeHookAnchor: () => null, // claude's shim already lives in the worktree (.claude/settings.json) — self-anchors, no root rewrite
|
|
1772
2079
|
contractFiles: (proj) => [join(proj, 'CLAUDE.md')],
|
|
1773
2080
|
skillDir: (proj) => join(proj, '.claude', 'skills'),
|
|
@@ -1775,7 +2082,7 @@ export const claudeHarness: Harness = {
|
|
|
1775
2082
|
shim: (dispatch, spex) => buildShim('claude', CLAUDE_EVENTS, dispatch, spex),
|
|
1776
2083
|
writeTrust: () => [], // Claude relies on folder-trust — no artifact to report
|
|
1777
2084
|
removeTrust: () => { /* Claude wrote no trust — nothing to strip */ },
|
|
1778
|
-
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
2085
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
1779
2086
|
slashCommands: claudeSlashCommands,
|
|
1780
2087
|
// online iff the window is up AND a LIVE LISTENER is on the rendezvous socket (`socketLive`, connect-probed by
|
|
1781
2088
|
// the caller) — NOT the mere existence of a stale socket FILE a crashed claude leaves behind (the 30-min
|
|
@@ -1825,6 +2132,7 @@ export const claudeHeadlessHarness: Harness = {
|
|
|
1825
2132
|
|
|
1826
2133
|
export const codexHarness: Harness = {
|
|
1827
2134
|
id: 'codex',
|
|
2135
|
+
dispatchId: 'codex',
|
|
1828
2136
|
headless: false,
|
|
1829
2137
|
sharedRuntimeSpawn: true,
|
|
1830
2138
|
events: CODEX_EVENTS,
|
|
@@ -1843,6 +2151,7 @@ export const codexHarness: Harness = {
|
|
|
1843
2151
|
// per-worktree (codex loads THOSE by walking the thread cwd). dispatch.sh resolves `proj` from the thread
|
|
1844
2152
|
// cwd, so one shared shim serves every worktree.
|
|
1845
2153
|
shimFile: (proj) => join(mainCheckout(proj), '.codex', 'hooks.json'),
|
|
2154
|
+
shimScope: 'project',
|
|
1846
2155
|
// a LINKED worktree also needs its OWN `.codex/hooks.json` so codex-rs anchors the project config layer for
|
|
1847
2156
|
// the worktree cwd (without a `.codex/` under the worktree root, codex builds no layer, so the rewritten
|
|
1848
2157
|
// root-checkout hooks are never discovered and NO hooks fire — bypass_hook_trust cannot rescue a layer that
|
|
@@ -1875,7 +2184,7 @@ export const codexHarness: Harness = {
|
|
|
1875
2184
|
writeTrust: (proj, cmdFor) => [writeCodexTrust(mainCheckout(proj), CODEX_EVENTS, cmdFor)],
|
|
1876
2185
|
// trust is keyed by the MAIN checkout (where the codex shim materializes) — strip it at the same key.
|
|
1877
2186
|
removeTrust: (proj) => removeCodexTrust(mainCheckout(proj)),
|
|
1878
|
-
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
2187
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
1879
2188
|
slashCommands: codexSlashCommands,
|
|
1880
2189
|
// online iff the tmux window is up AND the agent is live. PRIMARY: the launch-registered `agent.pid` hot-tier
|
|
1881
2190
|
// verdict (`pidAlive`) — a 100ms syscall (kill-0), no ps scan. LEGACY: a pre-registration session has no
|
|
@@ -1891,6 +2200,7 @@ export const codexHarness: Harness = {
|
|
|
1891
2200
|
},
|
|
1892
2201
|
leafOwnerNeedle: (rec) => rec.harnessSessionId ?? null,
|
|
1893
2202
|
deliver: (rec, text) => deliverViaCodexAppServer(rec, text),
|
|
2203
|
+
observeTurnFailures: codexTurnFailureObserver,
|
|
1894
2204
|
cleanupRuntime: async () => { /* project-scoped app-server is shared; no per-session transport to remove */ },
|
|
1895
2205
|
coldRetirementPreflight: async (rec) => {
|
|
1896
2206
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
@@ -1898,75 +2208,67 @@ export const codexHarness: Harness = {
|
|
|
1898
2208
|
const dir = runtimeRoot()
|
|
1899
2209
|
const generationBefore = codexRuntimeGeneration(dir)
|
|
1900
2210
|
if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
1901
|
-
const
|
|
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
|
-
])
|
|
2211
|
+
const result = await codexColdPreflight(threadId, dir, generationBefore)
|
|
1907
2212
|
if (codexRuntimeGeneration(dir) !== generationBefore)
|
|
1908
2213
|
return { ok: false, reason: 'shared Codex app-server generation changed during cold retirement guard' }
|
|
1909
|
-
if (
|
|
1910
|
-
if (!
|
|
1911
|
-
|
|
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})` }
|
|
2214
|
+
if (!result.ok) return result
|
|
2215
|
+
if (!result.alreadyCold)
|
|
2216
|
+
return { ok: false, reason: `Codex target subtree ${result.receipt.activeIds.join(', ')} is not fully archived` }
|
|
1917
2217
|
return { ok: true, alreadyCold: true }
|
|
1918
2218
|
},
|
|
1919
2219
|
coldPreflight: async (rec) => {
|
|
1920
2220
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
1921
|
-
|
|
1922
|
-
return result.ok ? { ok: true, ...(result.alreadyCold ? { alreadyCold: true } : {}) } : result
|
|
2221
|
+
return codexColdPreflight(rec.harnessSessionId)
|
|
1923
2222
|
},
|
|
1924
|
-
coldRuntime: async (rec) => {
|
|
2223
|
+
coldRuntime: async (rec, suppliedReceipt) => {
|
|
1925
2224
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
1926
2225
|
const threadId = rec.harnessSessionId
|
|
1927
|
-
const
|
|
1928
|
-
const
|
|
2226
|
+
const dir = runtimeRoot()
|
|
2227
|
+
const sock = codexAppServerSock(dir)
|
|
2228
|
+
const generationBefore = codexRuntimeGeneration(dir)
|
|
1929
2229
|
if (!generationBefore) return { ok: false, reason: 'Codex shared app-server generation is unproven' }
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
if (
|
|
1934
|
-
|
|
2230
|
+
if (suppliedReceipt !== undefined && (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== threadId))
|
|
2231
|
+
return { ok: false, reason: 'Codex cold teardown receipt is missing, malformed, or names a different target' }
|
|
2232
|
+
const frozenPlan = isCodexColdPlan(suppliedReceipt) ? suppliedReceipt : null
|
|
2233
|
+
if (frozenPlan && frozenPlan.generation !== generationBefore)
|
|
2234
|
+
return { ok: false, reason: 'shared Codex app-server generation changed after archive preflight' }
|
|
2235
|
+
const preflight = await codexColdPreflight(threadId, dir, frozenPlan?.generation ?? generationBefore)
|
|
2236
|
+
if (!preflight.ok) return preflight
|
|
2237
|
+
const plan = frozenPlan ?? preflight.receipt
|
|
2238
|
+
if (frozenPlan && (!sameIdSet(frozenPlan.descendantIds, preflight.receipt.descendantIds) ||
|
|
2239
|
+
!sameParentEdges(frozenPlan.parentEdges, preflight.receipt.parentEdges) ||
|
|
2240
|
+
!sameIdSet(frozenPlan.activeIds, preflight.receipt.activeIds) ||
|
|
2241
|
+
!sameIdSet(frozenPlan.archivedIds, preflight.receipt.archivedIds)))
|
|
2242
|
+
return { ok: false, reason: 'Codex target subtree ownership or collection assignment changed after archive preflight' }
|
|
2243
|
+
if (codexRuntimeGeneration(dir) !== plan.generation)
|
|
2244
|
+
return { ok: false, reason: 'shared Codex app-server generation changed during target subtree guard' }
|
|
2245
|
+
if (plan.activeIds.length === 0) return { ok: true }
|
|
2246
|
+
const subtreeSet = new Set(plan.subtreeIds)
|
|
2247
|
+
const siblingBefore = plan.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId))
|
|
2248
|
+
const fence = { dir, generation: plan.generation }
|
|
2249
|
+
|
|
2250
|
+
const compensate = async (reason: string): Promise<{ ok: false; reason: string }> => {
|
|
2251
|
+
const restored = await codexRestoreColdPlan(plan, dir)
|
|
2252
|
+
return { ok: false, reason: restored.ok ? reason : `${reason}; ${restored.reason}` }
|
|
2253
|
+
}
|
|
2254
|
+
|
|
1935
2255
|
const coldCheck = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
|
|
1936
|
-
const
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
if (
|
|
1942
|
-
|
|
1943
|
-
if (!
|
|
1944
|
-
|
|
1945
|
-
|
|
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))
|
|
2256
|
+
const after = await codexColdPreflight(threadId, dir, plan.generation)
|
|
2257
|
+
if (!after.ok) return after
|
|
2258
|
+
if (codexRuntimeGeneration(dir) !== plan.generation) return { ok: false, reason: 'shared Codex app-server generation changed during archive' }
|
|
2259
|
+
if (!sameIdSet(plan.descendantIds, after.receipt.descendantIds) || !sameParentEdges(plan.parentEdges, after.receipt.parentEdges))
|
|
2260
|
+
return { ok: false, reason: `Codex target descendant closure changed during archive (before=${plan.descendantIds.join(', ')}; after=${after.receipt.descendantIds.join(', ')})` }
|
|
2261
|
+
if (after.receipt.activeIds.length)
|
|
2262
|
+
return { ok: false, reason: `Codex target subtree remains in the active collection (${after.receipt.activeIds.join(', ')})` }
|
|
2263
|
+
if (!sameIdSet(plan.subtreeIds, after.receipt.archivedIds))
|
|
2264
|
+
return { ok: false, reason: 'Codex target subtree is not uniquely archived after cold teardown' }
|
|
2265
|
+
const afterIds = new Set(after.receipt.guard.referenceIds.filter((referenceId) => !subtreeSet.has(referenceId)))
|
|
1950
2266
|
if (siblingBefore.some((referenceId) => !afterIds.has(referenceId))) return { ok: false, reason: 'a pre-existing shared Codex sibling reference disappeared during archive' }
|
|
1951
2267
|
return { ok: true }
|
|
1952
2268
|
}
|
|
1953
|
-
const
|
|
1954
|
-
|
|
1955
|
-
|
|
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` }
|
|
2269
|
+
for (const id of plan.activeIds) {
|
|
2270
|
+
const archived = await codexThreadMutation(sock, 'thread/archive', id, fence)
|
|
2271
|
+
if (!archived.ok) return compensate(`${archived.error} while archiving Codex subtree member ${id}`)
|
|
1970
2272
|
}
|
|
1971
2273
|
let verified: { ok: true } | { ok: false; reason: string } = { ok: false, reason: 'Codex archive verification timed out' }
|
|
1972
2274
|
const verifyDeadline = Date.now() + 30_000
|
|
@@ -1976,15 +2278,15 @@ export const codexHarness: Harness = {
|
|
|
1976
2278
|
if (Date.now() < verifyDeadline) await new Promise((resolve) => setTimeout(resolve, 100))
|
|
1977
2279
|
}
|
|
1978
2280
|
if (verified.ok) return verified
|
|
1979
|
-
|
|
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}` }
|
|
2281
|
+
return compensate(verified.reason)
|
|
1985
2282
|
},
|
|
1986
|
-
restoreRuntime: async (rec) => {
|
|
2283
|
+
restoreRuntime: async (rec, suppliedReceipt) => {
|
|
1987
2284
|
if (!rec.harnessSessionId) return { ok: false, reason: 'no exact Codex thread identity is registered' }
|
|
2285
|
+
if (suppliedReceipt !== undefined) {
|
|
2286
|
+
if (!isCodexColdPlan(suppliedReceipt) || suppliedReceipt.threadId !== rec.harnessSessionId)
|
|
2287
|
+
return { ok: false, reason: 'Codex cold compensation receipt is invalid or names a different target' }
|
|
2288
|
+
return codexRestoreColdPlan(suppliedReceipt)
|
|
2289
|
+
}
|
|
1988
2290
|
const sock = codexAppServerSock(runtimeRoot())
|
|
1989
2291
|
const reconcile = async (): Promise<{ ok: true } | { ok: false; reason: string }> => {
|
|
1990
2292
|
const [active, archived] = await Promise.all([
|
|
@@ -2006,7 +2308,7 @@ export const codexHarness: Harness = {
|
|
|
2006
2308
|
key: 'codex-app-server',
|
|
2007
2309
|
label: 'Codex app-server',
|
|
2008
2310
|
pidFile: codexAppServerPid(runtimeDir),
|
|
2009
|
-
|
|
2311
|
+
receiptFile: codexAppServerReceipt(runtimeDir),
|
|
2010
2312
|
residency: async () => {
|
|
2011
2313
|
const sock = codexAppServerSock(runtimeDir)
|
|
2012
2314
|
let pid = 0
|
|
@@ -2021,7 +2323,7 @@ export const codexHarness: Harness = {
|
|
|
2021
2323
|
const result = await codexLoadedReferenceIds(sock)
|
|
2022
2324
|
return result.ok ? { healthy: true, referenceIds: result.referenceIds } : { healthy: false, referenceIds: [], error: result.error }
|
|
2023
2325
|
},
|
|
2024
|
-
mutationGuard: (targetReferenceId) =>
|
|
2326
|
+
mutationGuard: (targetReferenceId, opts) => codexMutationGuard(targetReferenceId, runtimeDir, opts),
|
|
2025
2327
|
probe: () => codexSharedRuntimeProbe(runtimeDir),
|
|
2026
2328
|
}],
|
|
2027
2329
|
// owned thread id → `--resume <id>` MARKER the codex launch script reads to resume that thread DIRECTLY (NOT
|
|
@@ -2167,6 +2469,7 @@ export const codexHeadlessHarness: Harness = {
|
|
|
2167
2469
|
// one-run defence. See pi-harness.ts for the extension source + trust mechanics.
|
|
2168
2470
|
export const piHarness: Harness = {
|
|
2169
2471
|
id: 'pi',
|
|
2472
|
+
dispatchId: 'pi',
|
|
2170
2473
|
headless: false,
|
|
2171
2474
|
events: PI_EVENTS,
|
|
2172
2475
|
ownsRendezvous: true, // the generated extension binds rvSock(id) and speaks the reclaude protocol
|
|
@@ -2177,6 +2480,7 @@ export const piHarness: Harness = {
|
|
|
2177
2480
|
sessionEnvVar: 'PI_SESSION_ID', // exported by the generated extension at session_start; tool subprocesses inherit it
|
|
2178
2481
|
launchEnv: rendezvousLaunchEnv,
|
|
2179
2482
|
shimFile: (proj) => join(proj, '.pi', 'extensions', 'spexcode.ts'),
|
|
2483
|
+
shimScope: 'tree',
|
|
2180
2484
|
worktreeHookAnchor: () => null, // the extension lives in the worktree and self-anchors, like claude
|
|
2181
2485
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // pi auto-loads AGENTS.md context files (shared with codex — writeManagedBlock is idempotent)
|
|
2182
2486
|
skillDir: (proj) => join(proj, '.pi', 'skills'), // Agent Skills standard dirs, discovered after project trust
|
|
@@ -2187,7 +2491,7 @@ export const piHarness: Harness = {
|
|
|
2187
2491
|
}),
|
|
2188
2492
|
writeTrust: (proj) => [writePiTrust(mainCheckout(proj))], // trust keys on the MAIN checkout; nearest-parent lookup covers worktrees
|
|
2189
2493
|
removeTrust: (proj) => removePiTrust(mainCheckout(proj)),
|
|
2190
|
-
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
2494
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
2191
2495
|
slashCommands: piSlashCommands,
|
|
2192
2496
|
// claude's exact liveness: the window is up AND a live LISTENER answers on the rendezvous socket — the
|
|
2193
2497
|
// socket the generated extension binds. socketLive is already probed for every windowed session.
|
|
@@ -2222,6 +2526,7 @@ export const piHeadlessHarness: Harness = {
|
|
|
2222
2526
|
|
|
2223
2527
|
export const opencodeHarness: Harness = {
|
|
2224
2528
|
id: 'opencode',
|
|
2529
|
+
dispatchId: 'opencode',
|
|
2225
2530
|
headless: false,
|
|
2226
2531
|
events: OPENCODE_EVENTS,
|
|
2227
2532
|
// LITERALLY true: the generated plugin ([[opencode-harness]], opencode.ts) BINDS the per-session rendezvous
|
|
@@ -2241,6 +2546,7 @@ export const opencodeHarness: Harness = {
|
|
|
2241
2546
|
// the "shim" is a generated opencode PLUGIN in the worktree's own tree — opencode auto-loads project plugins
|
|
2242
2547
|
// by walking the cwd, so like claude it self-anchors and needs no root-checkout rewrite or worktree anchor.
|
|
2243
2548
|
shimFile: (proj) => join(proj, '.opencode', 'plugins', 'spexcode.ts'),
|
|
2549
|
+
shimScope: 'tree',
|
|
2244
2550
|
worktreeHookAnchor: () => null,
|
|
2245
2551
|
contractFiles: (proj) => [join(proj, 'AGENTS.md')], // opencode reads AGENTS.md natively (same file codex owns; the managed block is idempotent across writers)
|
|
2246
2552
|
skillDir: (proj) => join(proj, '.opencode', 'skills'),
|
|
@@ -2250,7 +2556,7 @@ export const opencodeHarness: Harness = {
|
|
|
2250
2556
|
shim: (dispatch, spex) => ({ content: opencodePluginSource(dispatch, spex), cmd: (e) => `SPEX='${spex}' bash ${dispatch} opencode ${e}` }),
|
|
2251
2557
|
writeTrust: () => [], // permission policy stays with the launcher command; no trust artifact to report
|
|
2252
2558
|
removeTrust: () => { /* nothing was written */ },
|
|
2253
|
-
clean(proj, arts) { cleanHarness(this, proj, arts) },
|
|
2559
|
+
clean(proj, arts, preserveProject) { cleanHarness(this, proj, arts, preserveProject) },
|
|
2254
2560
|
slashCommands: opencodeSlashCommands,
|
|
2255
2561
|
// online iff the window is up AND the agent answers on a channel: PREFER the rendezvous socket listener
|
|
2256
2562
|
// (the plugin is alive), FALL BACK to the launch-registered agent.pid (kill-0) so a plugin that failed to
|