spexcode 0.5.1 → 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 +116 -66
- package/spec-cli/src/cli.ts +221 -72
- 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 +965 -70
- 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/host.ts +1 -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 +117 -7
- package/spec-cli/src/opencode.ts +8 -4
- package/spec-cli/src/pi-headless.ts +18 -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 +37 -7
- package/spec-cli/src/pty-helper.mjs +16 -6
- package/spec-cli/src/pty-native-helper.mjs +22 -0
- 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 +29 -23
- package/spec-cli/src/sessions.ts +1262 -200
- 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-B_dKCuXx.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-C_w_wdk5.js +0 -27
- package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
- package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
- package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
- package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
- package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
- package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
- package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
- package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
- package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
- package/spec-dashboard/dist/assets/index-DcnCaBAC.css +0 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { once } from 'node:events'
|
|
3
|
+
import type { Duplex, Readable, Writable } from 'node:stream'
|
|
4
|
+
import type { Capability, LeaseOwner, MaintenanceState } from './session-maintenance.js'
|
|
5
|
+
import { processStartToken } from './process-identity.js'
|
|
6
|
+
import {
|
|
7
|
+
clientMaintenanceAcquire,
|
|
8
|
+
clientMaintenanceHeartbeat,
|
|
9
|
+
clientMaintenanceOperation,
|
|
10
|
+
clientMaintenanceRelease,
|
|
11
|
+
clientMaintenanceStatus,
|
|
12
|
+
} from './client.js'
|
|
13
|
+
|
|
14
|
+
type BrokerRequest = {
|
|
15
|
+
v?: number
|
|
16
|
+
id?: string
|
|
17
|
+
op?: string
|
|
18
|
+
sessionId?: string
|
|
19
|
+
force?: boolean
|
|
20
|
+
client?: { pid?: number; startToken?: string }
|
|
21
|
+
}
|
|
22
|
+
type PlannedCapability = {
|
|
23
|
+
capability: Capability
|
|
24
|
+
state: 'unused' | 'inflight' | 'committed' | 'indeterminate'
|
|
25
|
+
requestId?: string
|
|
26
|
+
}
|
|
27
|
+
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
28
|
+
const capabilityKey = (capability: Capability): string => JSON.stringify(capability)
|
|
29
|
+
const capabilityList = (rows: unknown): Capability[] => Array.isArray(rows)
|
|
30
|
+
? rows.map((row: any) => row?.capability ?? row)
|
|
31
|
+
: []
|
|
32
|
+
const sameCapabilities = (left: readonly Capability[], right: readonly Capability[]): boolean =>
|
|
33
|
+
JSON.stringify(left) === JSON.stringify(right)
|
|
34
|
+
const sameOwner = (left: LeaseOwner | null | undefined, right: LeaseOwner): boolean =>
|
|
35
|
+
!!left && left.instanceId === right.instanceId && left.pid === right.pid && left.startToken === right.startToken
|
|
36
|
+
const assertLeaseView = (view: Pick<MaintenanceState, 'state' | 'epoch' | 'owner' | 'capabilities'>, epoch: number, owner: LeaseOwner, capabilities: readonly Capability[], allowDraining = false) => {
|
|
37
|
+
if (view.epoch !== epoch || (!allowDraining && view.state !== 'active') || (allowDraining && view.state !== 'active' && view.state !== 'draining'))
|
|
38
|
+
throw new Error('maintenance lease changed epoch or state')
|
|
39
|
+
if (!sameOwner(view.owner, owner)) throw new Error('maintenance lease owner generation changed')
|
|
40
|
+
if (!sameCapabilities(capabilityList(view.capabilities), capabilities)) throw new Error('maintenance lease changed its capability plan')
|
|
41
|
+
}
|
|
42
|
+
const wait = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
43
|
+
const reapCommandGroup = async (child: ReturnType<typeof spawn>, closed: Promise<[number | null, NodeJS.Signals | null]>) => {
|
|
44
|
+
if (!child.pid) return
|
|
45
|
+
const groupAlive = () => {
|
|
46
|
+
try { process.kill(-child.pid!, 0); return true } catch { return false }
|
|
47
|
+
}
|
|
48
|
+
if (groupAlive()) {
|
|
49
|
+
try { process.kill(-child.pid, 'SIGTERM') } catch { /* already exited */ }
|
|
50
|
+
const deadline = Date.now() + 1_000
|
|
51
|
+
while (groupAlive() && Date.now() < deadline) await wait(20)
|
|
52
|
+
if (groupAlive()) {
|
|
53
|
+
try { process.kill(-child.pid, 'SIGKILL') } catch { /* already exited */ }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const closedAfterReap = await new Promise<boolean>((resolve) => {
|
|
57
|
+
let settled = false
|
|
58
|
+
const finish = (value: boolean) => {
|
|
59
|
+
if (settled) return
|
|
60
|
+
settled = true
|
|
61
|
+
clearTimeout(timer)
|
|
62
|
+
resolve(value)
|
|
63
|
+
}
|
|
64
|
+
const timer = setTimeout(() => finish(false), 2_000)
|
|
65
|
+
timer.unref()
|
|
66
|
+
void closed.then(() => finish(true))
|
|
67
|
+
})
|
|
68
|
+
if (!closedAfterReap) throw new Error('maintenance operator pipes did not close after exact process-group reap')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runMaintenanceWrapper(input: {
|
|
72
|
+
capabilities: Capability[]
|
|
73
|
+
ttlMs: number
|
|
74
|
+
waitMs: number
|
|
75
|
+
command: string[]
|
|
76
|
+
}): Promise<number> {
|
|
77
|
+
const acquired = await clientMaintenanceAcquire(input.capabilities, input.ttlMs, input.waitMs)
|
|
78
|
+
const { token, epoch, owner } = acquired.lease
|
|
79
|
+
const releaseable = typeof token === 'string' && /^[0-9a-f]{64}$/i.test(token) && Number.isSafeInteger(epoch)
|
|
80
|
+
let released = false
|
|
81
|
+
let authorityLost = false
|
|
82
|
+
let heartbeat: ReturnType<typeof setInterval> | null = null
|
|
83
|
+
let child: ReturnType<typeof spawn> | null = null
|
|
84
|
+
const pendingOperations = new Set<AbortController>()
|
|
85
|
+
let closeAdmission = () => {}
|
|
86
|
+
try {
|
|
87
|
+
if (!releaseable) throw new Error('maintenance acquire returned malformed private authority')
|
|
88
|
+
if (!owner || typeof owner.instanceId !== 'string' || !Number.isInteger(owner.pid) || typeof owner.startToken !== 'string')
|
|
89
|
+
throw new Error('maintenance acquire returned malformed supervisor authority')
|
|
90
|
+
if (!sameCapabilities(capabilityList(acquired.lease.capabilities), input.capabilities)) throw new Error('maintenance acquire returned a different capability plan')
|
|
91
|
+
|
|
92
|
+
let state = acquired.lease.state
|
|
93
|
+
const activationDeadline = Date.now() + input.ttlMs
|
|
94
|
+
try {
|
|
95
|
+
while (state === 'draining') {
|
|
96
|
+
const current = await clientMaintenanceHeartbeat(token, epoch, input.ttlMs)
|
|
97
|
+
assertLeaseView(current, epoch, owner, input.capabilities, true)
|
|
98
|
+
const status = await clientMaintenanceStatus()
|
|
99
|
+
assertLeaseView(status, epoch, owner, input.capabilities, true)
|
|
100
|
+
state = status.state as 'draining' | 'active'
|
|
101
|
+
if (state !== 'active') {
|
|
102
|
+
if (Date.now() >= activationDeadline) throw new Error('maintenance acquisition did not become active before its ttl')
|
|
103
|
+
await sleep(50)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (state !== 'active') throw new Error(`maintenance acquisition entered unexpected state ${state}`)
|
|
107
|
+
} catch (error) {
|
|
108
|
+
authorityLost = true
|
|
109
|
+
throw error
|
|
110
|
+
}
|
|
111
|
+
if (!input.command.length) throw new Error('maintenance wrapper needs a command after --')
|
|
112
|
+
|
|
113
|
+
const plan: PlannedCapability[] = input.capabilities.map((capability) => ({ capability: { ...capability }, state: 'unused' }))
|
|
114
|
+
let heartbeatFailure: Error | null = null
|
|
115
|
+
let heartbeatBusy = false
|
|
116
|
+
let failAuthority!: (error: Error) => void
|
|
117
|
+
const authorityFailure = new Promise<never>((_, reject) => {
|
|
118
|
+
failAuthority = (error) => {
|
|
119
|
+
if (heartbeatFailure) return
|
|
120
|
+
heartbeatFailure = error
|
|
121
|
+
reject(error)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
heartbeat = setInterval(() => {
|
|
125
|
+
if (heartbeatBusy || heartbeatFailure) return
|
|
126
|
+
heartbeatBusy = true
|
|
127
|
+
void clientMaintenanceHeartbeat(token, epoch, input.ttlMs)
|
|
128
|
+
.then((current) => assertLeaseView(current, epoch, owner, input.capabilities))
|
|
129
|
+
.catch((error) => failAuthority(error instanceof Error ? error : new Error(String(error))))
|
|
130
|
+
.finally(() => { heartbeatBusy = false })
|
|
131
|
+
}, Math.max(250, Math.min(1_000, Math.floor(input.ttlMs / 3))))
|
|
132
|
+
heartbeat.unref()
|
|
133
|
+
|
|
134
|
+
child = spawn(input.command[0], input.command.slice(1), {
|
|
135
|
+
stdio: ['inherit', 'inherit', 'inherit', 'pipe', 'pipe', 'pipe'],
|
|
136
|
+
env: { ...process.env, SPEXCODE_MAINTENANCE_BROKER_FDS: '3,4,5' },
|
|
137
|
+
detached: true,
|
|
138
|
+
})
|
|
139
|
+
const exited = once(child, 'exit') as Promise<[number | null, NodeJS.Signals | null]>
|
|
140
|
+
const closed = once(child, 'close') as Promise<[number | null, NodeJS.Signals | null]>
|
|
141
|
+
const childStdio = child.stdio as Array<Readable | Writable | Duplex | null | undefined>
|
|
142
|
+
const requestPipe = childStdio[3] as Readable
|
|
143
|
+
const responsePipe = childStdio[4] as Writable
|
|
144
|
+
const turnPipe = childStdio[5] as Duplex
|
|
145
|
+
requestPipe.setEncoding('utf8')
|
|
146
|
+
let pending = ''
|
|
147
|
+
let serial = Promise.resolve()
|
|
148
|
+
let brokerWork = 0
|
|
149
|
+
let brokerOpen = true
|
|
150
|
+
let transportLossTimer: ReturnType<typeof setTimeout> | null = null
|
|
151
|
+
const brokerTransportLost = (error: unknown) => {
|
|
152
|
+
if (!brokerOpen || transportLossTimer) return
|
|
153
|
+
transportLossTimer = setTimeout(() => {
|
|
154
|
+
transportLossTimer = null
|
|
155
|
+
if (brokerOpen && child?.exitCode === null && child.signalCode === null) {
|
|
156
|
+
failAuthority(error instanceof Error ? error : new Error('maintenance broker transport closed while the operator command was live'))
|
|
157
|
+
}
|
|
158
|
+
}, 50)
|
|
159
|
+
transportLossTimer.unref()
|
|
160
|
+
}
|
|
161
|
+
requestPipe.on('error', brokerTransportLost)
|
|
162
|
+
requestPipe.on('end', () => brokerTransportLost(new Error('maintenance broker request pipe closed while the operator command was live')))
|
|
163
|
+
responsePipe.on('error', brokerTransportLost)
|
|
164
|
+
responsePipe.on('close', () => brokerTransportLost(new Error('maintenance broker response pipe closed while the operator command was live')))
|
|
165
|
+
turnPipe.on('error', brokerTransportLost)
|
|
166
|
+
turnPipe.on('end', () => brokerTransportLost(new Error('maintenance broker turn pipe closed while the operator command was live')))
|
|
167
|
+
closeAdmission = () => {
|
|
168
|
+
if (!brokerOpen) return
|
|
169
|
+
brokerOpen = false
|
|
170
|
+
for (const controller of pendingOperations) controller.abort(new Error('maintenance broker admission closed'))
|
|
171
|
+
if (transportLossTimer) clearTimeout(transportLossTimer)
|
|
172
|
+
transportLossTimer = null
|
|
173
|
+
try { requestPipe.destroy() } catch { /* already closed */ }
|
|
174
|
+
try { turnPipe.destroy() } catch { /* already closed */ }
|
|
175
|
+
}
|
|
176
|
+
turnPipe.on('data', (chunk: Buffer) => {
|
|
177
|
+
if (!brokerOpen) return
|
|
178
|
+
for (const byte of chunk) {
|
|
179
|
+
if (byte !== 1) return failAuthority(new Error('maintenance broker turn token was corrupted'))
|
|
180
|
+
turnPipe.write(Buffer.from([1]))
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
turnPipe.write(Buffer.from([1]))
|
|
184
|
+
const reply = (id: string | undefined, body: Record<string, unknown>) => {
|
|
185
|
+
if (brokerOpen) responsePipe.write(`${JSON.stringify({ id, ...body })}\n`)
|
|
186
|
+
}
|
|
187
|
+
const handle = async (request: BrokerRequest) => {
|
|
188
|
+
if (!brokerOpen) return
|
|
189
|
+
if (request.v !== 1 || typeof request.id !== 'string' || typeof request.op !== 'string' || typeof request.sessionId !== 'string'
|
|
190
|
+
|| !Number.isInteger(request.client?.pid) || typeof request.client?.startToken !== 'string'
|
|
191
|
+
|| processStartToken(request.client.pid!) !== request.client.startToken) {
|
|
192
|
+
reply(request.id, { ok: false, code: 'maintenance_invalid', error: 'malformed broker request' })
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
const candidate: Capability | null = request.op === 'stop'
|
|
196
|
+
? { op: 'stop', sessionId: request.sessionId }
|
|
197
|
+
: request.op === 'resume' && typeof request.force === 'boolean'
|
|
198
|
+
? { op: 'resume', sessionId: request.sessionId, force: request.force }
|
|
199
|
+
: null
|
|
200
|
+
const matching = candidate && plan.find((entry) => capabilityKey(entry.capability) === capabilityKey(candidate))
|
|
201
|
+
const planned = matching?.state === 'unused' ? matching : null
|
|
202
|
+
if (!planned) {
|
|
203
|
+
reply(request.id, {
|
|
204
|
+
ok: false,
|
|
205
|
+
code: matching ? 'maintenance_capability_used' : 'maintenance_capability_missing',
|
|
206
|
+
error: matching ? 'operation is already inflight, committed, or indeterminate' : 'operation is not in the exact maintenance plan',
|
|
207
|
+
})
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
planned.state = 'inflight'
|
|
211
|
+
planned.requestId = request.id
|
|
212
|
+
const controller = new AbortController()
|
|
213
|
+
pendingOperations.add(controller)
|
|
214
|
+
try {
|
|
215
|
+
const result = await clientMaintenanceOperation(token, candidate!, { signal: controller.signal, timeoutMs: Math.min(5_000, input.ttlMs) })
|
|
216
|
+
planned.state = result.outcome === 'committed' ? 'committed' : result.outcome === 'refused' ? 'unused' : 'indeterminate'
|
|
217
|
+
delete planned.requestId
|
|
218
|
+
reply(request.id, result)
|
|
219
|
+
} catch (error) {
|
|
220
|
+
planned.state = 'indeterminate'
|
|
221
|
+
delete planned.requestId
|
|
222
|
+
reply(request.id, { ok: false, code: 'maintenance_broker_failed', error: error instanceof Error ? error.message : String(error) })
|
|
223
|
+
} finally {
|
|
224
|
+
pendingOperations.delete(controller)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
requestPipe.on('data', (chunk: Buffer | string) => {
|
|
228
|
+
pending += chunk.toString()
|
|
229
|
+
for (;;) {
|
|
230
|
+
const newline = pending.indexOf('\n')
|
|
231
|
+
if (newline < 0) break
|
|
232
|
+
const line = pending.slice(0, newline); pending = pending.slice(newline + 1)
|
|
233
|
+
if (!line) continue
|
|
234
|
+
brokerWork++
|
|
235
|
+
serial = serial.then(async () => {
|
|
236
|
+
try { await handle(JSON.parse(line) as BrokerRequest) }
|
|
237
|
+
catch (error) { reply(undefined, { ok: false, code: 'maintenance_broker_failed', error: error instanceof Error ? error.message : String(error) }) }
|
|
238
|
+
}).finally(() => { brokerWork-- })
|
|
239
|
+
}
|
|
240
|
+
})
|
|
241
|
+
const outcome = await Promise.race([
|
|
242
|
+
exited.then(([code, signal]) => ({ kind: 'exit' as const, code, signal })),
|
|
243
|
+
authorityFailure.catch((error) => ({ kind: 'lost' as const, error: error instanceof Error ? error : new Error(String(error)) })),
|
|
244
|
+
])
|
|
245
|
+
if (outcome.kind === 'lost') {
|
|
246
|
+
authorityLost = true
|
|
247
|
+
closeAdmission()
|
|
248
|
+
await reapCommandGroup(child, closed)
|
|
249
|
+
throw outcome.error
|
|
250
|
+
}
|
|
251
|
+
const brokerIndeterminate = brokerWork > 0 || pendingOperations.size > 0 || pending.trim().length > 0
|
|
252
|
+
if (brokerIndeterminate) authorityLost = true
|
|
253
|
+
closeAdmission()
|
|
254
|
+
await serial
|
|
255
|
+
try { await reapCommandGroup(child, closed) }
|
|
256
|
+
catch (error) { authorityLost = true; throw error }
|
|
257
|
+
if (brokerIndeterminate) {
|
|
258
|
+
throw new Error('operator exited while a maintenance broker operation was indeterminate')
|
|
259
|
+
}
|
|
260
|
+
if (heartbeatFailure) {
|
|
261
|
+
authorityLost = true
|
|
262
|
+
throw heartbeatFailure
|
|
263
|
+
}
|
|
264
|
+
return outcome.code ?? (outcome.signal ? 1 : 0)
|
|
265
|
+
} finally {
|
|
266
|
+
if (heartbeat) clearInterval(heartbeat)
|
|
267
|
+
closeAdmission()
|
|
268
|
+
try { child?.stdio[3]?.destroy() } catch { /* child already closed */ }
|
|
269
|
+
try { child?.stdio[4]?.destroy() } catch { /* child already closed */ }
|
|
270
|
+
try { (child?.stdio as Array<Readable | Writable | null | undefined> | undefined)?.[5]?.destroy() } catch { /* child already closed */ }
|
|
271
|
+
if (releaseable && !released && !authorityLost) {
|
|
272
|
+
await clientMaintenanceRelease(token, epoch)
|
|
273
|
+
released = true
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -23,7 +23,7 @@ export type MaterializeResult = { contentHash: string; planted: MaterializedArti
|
|
|
23
23
|
// a trigger. It turns the spec tree's surface nodes into the flat artifacts each consumer reads
|
|
24
24
|
// cheaply, so a USER-self-launched claude/codex (no SpexCode process in the launch) gets the whole system via
|
|
25
25
|
// harness-auto-discovered files: (1) the hook MANIFEST (our dispatcher reads it), (2) the CONTRACT — the
|
|
26
|
-
//
|
|
26
|
+
// surface:system plugin bodies (in name order) — written WHOLE into each
|
|
27
27
|
// harness's contract file(s), (3) the thin SHIMS (every event → dispatch.sh), (4) the per-harness TRUST
|
|
28
28
|
// (Codex's deterministic trusted_hash; Claude none). EVERY harness-specific fact is owned by the
|
|
29
29
|
// [[harness-adapter]] (harness.ts) — this file just loops over HARNESSES.
|
|
@@ -192,13 +192,12 @@ export function materialize(proj = process.cwd()): MaterializeResult {
|
|
|
192
192
|
const manifest = join(rt, 'hooks-manifest')
|
|
193
193
|
writeFileSync(manifest, compileManifest())
|
|
194
194
|
record('hook manifest', manifest)
|
|
195
|
-
// (2) the contract = the
|
|
196
|
-
// in-tree prose) FOLLOWED BY the surface:system bodies (in name order), written WHOLE into EACH harness's
|
|
195
|
+
// (2) the contract = the surface:system plugin bodies (in name order), written WHOLE into EACH harness's
|
|
197
196
|
// contract file(s) + (3) each harness's thin shim → dispatch.sh + (4) its trust. All owned by the adapter.
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const contract =
|
|
197
|
+
// ONE source, no per-project escape hatch: the contract IS the surface:system plugin bodies. A project's
|
|
198
|
+
// own hand-written prose is not folded in — repo-local notes belong in the harness file's own
|
|
199
|
+
// block-outside region (untracked, per-clone), and anything that must reach EVERY agent is a plugin node.
|
|
200
|
+
const contract = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean).join('\n\n')
|
|
202
201
|
// WHICH harnesses to deliver into ([[harness-select]]): the spexcode.json `harnesses` set (default = every
|
|
203
202
|
// native harness). resolveHarnessTargets FAILS LOUD on an illegal set (plugin+native, plugin w/o folder).
|
|
204
203
|
const cfg = readConfig(mainCheckout(proj))
|
|
@@ -1,17 +1,46 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process'
|
|
2
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir, userInfo } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
2
5
|
import { promisify } from 'node:util'
|
|
3
|
-
import type
|
|
6
|
+
import { headlessTurnFailureShell, type DispatchResult, type HarnessDeliveryRecord } from './harness.js'
|
|
7
|
+
import { shQuote } from './sh.js'
|
|
4
8
|
|
|
5
9
|
const pexec = promisify(execFile)
|
|
10
|
+
const WAKE_EARLY_EXIT_MS = 5_000
|
|
11
|
+
const OUTCOME_POLL_MS = 25
|
|
6
12
|
|
|
7
|
-
const
|
|
13
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
14
|
+
|
|
15
|
+
function accountLoginShell(): string {
|
|
16
|
+
try { return userInfo().shell || '' } catch { return '' }
|
|
17
|
+
}
|
|
8
18
|
|
|
9
19
|
// A headless turn owns the pane only while `opencode run` is alive. Returning to a shell keeps the tmux
|
|
10
20
|
// window as the session's durable home without adding a resident controller or an stdin bridge.
|
|
11
|
-
function turnHome(command: string): string {
|
|
21
|
+
function turnHome(command: string, outcomePath?: string): string {
|
|
22
|
+
const outcomeSetup = outcomePath ? [
|
|
23
|
+
`__spex_outcome_path=${shQuote(outcomePath)}`,
|
|
24
|
+
`__spex_outcome_tmp=${shQuote(`${outcomePath}.tmp`)}`,
|
|
25
|
+
'__spex_outcome() { printf \'%s\\n\' "$1" > "$__spex_outcome_tmp" && mv -f "$__spex_outcome_tmp" "$__spex_outcome_path"; }',
|
|
26
|
+
'__spex_outcome "running:$$" || { printf "[spex opencode-headless] could not create turn outcome marker\\n" >&2; exit 125; }',
|
|
27
|
+
] : []
|
|
28
|
+
const failure = outcomePath ? [
|
|
29
|
+
'__spex_cas_rc=0',
|
|
30
|
+
'if [ "$__spex_rc" -ne 0 ]; then',
|
|
31
|
+
' __spex_outcome "reporting:$$:$__spex_rc" || true',
|
|
32
|
+
` ${headlessTurnFailureShell('opencode-headless', false)}`,
|
|
33
|
+
' __spex_cas_rc=$?',
|
|
34
|
+
'fi',
|
|
35
|
+
'__spex_outcome "exit:$__spex_rc:cas:$__spex_cas_rc" || printf "[spex opencode-headless] could not finalize turn outcome marker\\n" >&2',
|
|
36
|
+
] : [
|
|
37
|
+
`if [ "$__spex_rc" -ne 0 ]; then ${headlessTurnFailureShell('opencode-headless')}; fi`,
|
|
38
|
+
]
|
|
12
39
|
const script = [
|
|
40
|
+
...outcomeSetup,
|
|
13
41
|
command,
|
|
14
42
|
'__spex_rc=$?',
|
|
43
|
+
...failure,
|
|
15
44
|
'[ "$__spex_rc" -eq 0 ] || printf "[spex opencode-headless] turn exited rc=%s\\n" "$__spex_rc" >&2',
|
|
16
45
|
'exec "${SHELL:-/bin/sh}"',
|
|
17
46
|
].join('\n')
|
|
@@ -21,14 +50,19 @@ function turnHome(command: string): string {
|
|
|
21
50
|
// Launcher profiles carry a base executable plus its configured flags (`opencode --auto`). OpenCode parses
|
|
22
51
|
// `run` as a subcommand, so it must sit between those two halves (`opencode run --auto`), not at the tail.
|
|
23
52
|
function runPrelude(opencodeCmd: string): string[] {
|
|
53
|
+
const accountShell = accountLoginShell()
|
|
24
54
|
return [
|
|
55
|
+
'__spex_login_shell="${SHELL:-}"',
|
|
56
|
+
`[ -n "$__spex_login_shell" ] || __spex_login_shell=${shQuote(accountShell)}`,
|
|
25
57
|
`__spex_cmd=(${opencodeCmd})`,
|
|
26
58
|
'__spex_env=()',
|
|
27
59
|
'while [ "${#__spex_cmd[@]}" -gt 0 ] && [[ "${__spex_cmd[0]}" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; do',
|
|
28
60
|
' __spex_env+=("${__spex_cmd[0]}")',
|
|
29
61
|
' __spex_cmd=("${__spex_cmd[@]:1}")',
|
|
30
62
|
'done',
|
|
31
|
-
|
|
63
|
+
// Resolve OpenCode in the same login + interactive environment the user relies on. The launcher-leading
|
|
64
|
+
// assignments are applied after shell startup, so its explicit per-session config still wins.
|
|
65
|
+
'__spex_run() { [ -n "$__spex_login_shell" ] || { printf "[spex opencode-headless] no login shell could be resolved - turn NOT started\\n" >&2; return 126; }; "$__spex_login_shell" -ilc \'exec env "$@"\' spexcode-opencode-headless "${__spex_env[@]}" "${__spex_cmd[0]}" run "${__spex_cmd[@]:1}" "$@"; }',
|
|
32
66
|
]
|
|
33
67
|
}
|
|
34
68
|
|
|
@@ -53,7 +87,12 @@ export function opencodeHeadlessLaunchCommand(opencodeCmd = 'opencode'): string
|
|
|
53
87
|
return turnHome(script)
|
|
54
88
|
}
|
|
55
89
|
|
|
56
|
-
export function opencodeHeadlessWakeCommand(
|
|
90
|
+
export function opencodeHeadlessWakeCommand(
|
|
91
|
+
opencodeCmd: string,
|
|
92
|
+
harnessSessionId: string | null | undefined,
|
|
93
|
+
text: string,
|
|
94
|
+
outcomePath?: string,
|
|
95
|
+
): string {
|
|
57
96
|
const resume = harnessSessionId ? [
|
|
58
97
|
`export SPEXCODE_OPENCODE_RESUME_ID=${shQuote(harnessSessionId)}`,
|
|
59
98
|
'unset SPEXCODE_OPENCODE_CONTINUE',
|
|
@@ -63,7 +102,44 @@ export function opencodeHeadlessWakeCommand(opencodeCmd: string, harnessSessionI
|
|
|
63
102
|
'export SPEXCODE_OPENCODE_CONTINUE=1',
|
|
64
103
|
`__spex_run --continue ${shQuote(text)}`,
|
|
65
104
|
]
|
|
66
|
-
return turnHome([...runPrelude(opencodeCmd), ...resume].join('\n'))
|
|
105
|
+
return turnHome([...runPrelude(opencodeCmd), ...resume].join('\n'), outcomePath)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type TurnOutcome =
|
|
109
|
+
| { state: 'running'; pid: number }
|
|
110
|
+
| { state: 'reporting'; pid: number; code: number }
|
|
111
|
+
| { state: 'exit'; code: number; casCode: number }
|
|
112
|
+
| { state: 'invalid' }
|
|
113
|
+
|
|
114
|
+
function readTurnOutcome(path: string): TurnOutcome | undefined {
|
|
115
|
+
let value: string
|
|
116
|
+
try { value = readFileSync(path, 'utf8').trim() } catch (error) {
|
|
117
|
+
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined
|
|
118
|
+
throw error
|
|
119
|
+
}
|
|
120
|
+
let match = /^running:(\d+)$/.exec(value)
|
|
121
|
+
if (match) return { state: 'running', pid: Number(match[1]) }
|
|
122
|
+
match = /^reporting:(\d+):(-?\d+)$/.exec(value)
|
|
123
|
+
if (match) return { state: 'reporting', pid: Number(match[1]), code: Number(match[2]) }
|
|
124
|
+
match = /^exit:(-?\d+):cas:(\d+)$/.exec(value)
|
|
125
|
+
if (match) return { state: 'exit', code: Number(match[1]), casCode: Number(match[2]) }
|
|
126
|
+
return { state: 'invalid' }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function turnExited(rec: HarnessDeliveryRecord, outcome: Extract<TurnOutcome, { state: 'exit' }>): DispatchResult {
|
|
130
|
+
if (outcome.code === 0) return { ok: true }
|
|
131
|
+
const cas = outcome.casCode === 0 ? '' : `; error CAS reporter also exited with code ${outcome.casCode}`
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
error: `opencode-headless turn exited with code ${outcome.code} during startup for session ${rec.session}${cas} - prompt delivery FAILED`,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function pidAlive(pid: number): boolean {
|
|
139
|
+
if (!Number.isInteger(pid) || pid <= 0) return false
|
|
140
|
+
try { process.kill(pid, 0); return true } catch (error) {
|
|
141
|
+
return (error as NodeJS.ErrnoException).code === 'EPERM'
|
|
142
|
+
}
|
|
67
143
|
}
|
|
68
144
|
|
|
69
145
|
export async function spawnOpenCodeHeadlessTurn(
|
|
@@ -73,6 +149,11 @@ export async function spawnOpenCodeHeadlessTurn(
|
|
|
73
149
|
socketPath: string,
|
|
74
150
|
): Promise<DispatchResult> {
|
|
75
151
|
if (!rec.worktreePath) return { ok: false, error: `opencode-headless session ${rec.session} has no worktree path - turn NOT started` }
|
|
152
|
+
let outcomeDir: string
|
|
153
|
+
try { outcomeDir = mkdtempSync(join(tmpdir(), 'spexcode-oh-turn-')) } catch (error) {
|
|
154
|
+
return { ok: false, error: `opencode-headless could not prepare turn confirmation for session ${rec.session}: ${(error as Error).message}` }
|
|
155
|
+
}
|
|
156
|
+
const outcomePath = join(outcomeDir, 'exit-code')
|
|
76
157
|
const tmuxSock = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
77
158
|
const args = [
|
|
78
159
|
'-L', tmuxSock, 'respawn-pane', '-k', '-t', rec.session, '-c', rec.worktreePath,
|
|
@@ -84,12 +165,41 @@ export async function spawnOpenCodeHeadlessTurn(
|
|
|
84
165
|
const value = process.env[name]
|
|
85
166
|
if (value) args.push('-e', `${name}=${value}`)
|
|
86
167
|
}
|
|
87
|
-
args.push(opencodeHeadlessWakeCommand(opencodeCmd, rec.harnessSessionId, text))
|
|
168
|
+
args.push(opencodeHeadlessWakeCommand(opencodeCmd, rec.harnessSessionId, text, outcomePath))
|
|
88
169
|
try {
|
|
89
170
|
await pexec('tmux', args, { timeout: 5_000 })
|
|
171
|
+
const deadline = Date.now() + WAKE_EARLY_EXIT_MS
|
|
172
|
+
for (;;) {
|
|
173
|
+
const outcome = readTurnOutcome(outcomePath)
|
|
174
|
+
if (outcome?.state === 'exit') return turnExited(rec, outcome)
|
|
175
|
+
if (outcome?.state === 'invalid') {
|
|
176
|
+
return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - prompt delivery NOT confirmed` }
|
|
177
|
+
}
|
|
178
|
+
if (Date.now() >= deadline) break
|
|
179
|
+
await sleep(Math.min(OUTCOME_POLL_MS, deadline - Date.now()))
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const first = readTurnOutcome(outcomePath)
|
|
183
|
+
if (first?.state === 'exit') return turnExited(rec, first)
|
|
184
|
+
if (!first) return { ok: false, error: `opencode-headless turn for session ${rec.session} never confirmed startup - prompt delivery FAILED` }
|
|
185
|
+
if (first.state === 'invalid') return { ok: false, error: `opencode-headless turn for session ${rec.session} wrote an invalid exit outcome - prompt delivery NOT confirmed` }
|
|
186
|
+
if (first.state === 'reporting') {
|
|
187
|
+
return { ok: false, error: `opencode-headless turn exited with code ${first.code} but its error CAS reporter did not finish for session ${rec.session} - prompt delivery FAILED` }
|
|
188
|
+
}
|
|
189
|
+
if (!pidAlive(first.pid)) {
|
|
190
|
+
return { ok: false, error: `opencode-headless turn wrapper died before reporting an outcome for session ${rec.session} - prompt delivery FAILED` }
|
|
191
|
+
}
|
|
192
|
+
await sleep(OUTCOME_POLL_MS)
|
|
193
|
+
const settled = readTurnOutcome(outcomePath)
|
|
194
|
+
if (settled?.state === 'exit') return turnExited(rec, settled)
|
|
195
|
+
if (settled?.state !== 'running' || settled.pid !== first.pid || !pidAlive(settled.pid)) {
|
|
196
|
+
return { ok: false, error: `opencode-headless turn did not remain live through startup for session ${rec.session} - prompt delivery FAILED` }
|
|
197
|
+
}
|
|
90
198
|
return { ok: true }
|
|
91
199
|
} catch (error) {
|
|
92
200
|
const detail = error instanceof Error ? error.message : String(error)
|
|
93
201
|
return { ok: false, error: `opencode-headless could not start a turn for session ${rec.session}: ${detail}` }
|
|
202
|
+
} finally {
|
|
203
|
+
rmSync(outcomeDir, { recursive: true, force: true })
|
|
94
204
|
}
|
|
95
205
|
}
|
package/spec-cli/src/opencode.ts
CHANGED
|
@@ -120,10 +120,14 @@ export const SpexcodePlugin = async (ctx) => {
|
|
|
120
120
|
const sid = p.sessionID || ""
|
|
121
121
|
if (sid && rootSession && sid !== rootSession) return // a subagent going idle is not this worker's Stop
|
|
122
122
|
adopt(sid)
|
|
123
|
-
// the
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
123
|
+
// Start the Stop dispatch now, but RETURN this idle callback before a blocked verdict injects its
|
|
124
|
+
// follow-up turn. Awaiting client.session.prompt here re-enters opencode before it has published the
|
|
125
|
+
// just-finished assistant text: the continuation can declare success while the requested final answer
|
|
126
|
+
// disappears. This is opencode host scheduling, not shared dispatchStop semantics (pi correctly awaits
|
|
127
|
+
// its agent_end continuation). dispatchStop catches inject failures; this final catch keeps substrate
|
|
128
|
+
// failures loud without throwing them into opencode after the event callback has returned.
|
|
129
|
+
void rt.dispatchStop((reason) => injectPrompt(reason), "blocked by a spexcode hook")
|
|
130
|
+
.catch((e) => console.error("spexcode: Stop dispatch failed: " + String(e)))
|
|
127
131
|
}
|
|
128
132
|
},
|
|
129
133
|
"chat.message": async (input, output) => {
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createServer, type Server, type Socket } from 'node:net'
|
|
2
2
|
import { spawn, type ChildProcess } from 'node:child_process'
|
|
3
3
|
import { mkdirSync, rmSync } from 'node:fs'
|
|
4
4
|
import { tmpdir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import type { DispatchResult, HarnessDeliveryRecord } from './harness.js'
|
|
8
|
+
import { controlRequest, withTimeout } from './headless-controller.js'
|
|
9
|
+
import { shQuote } from './sh.js'
|
|
8
10
|
|
|
9
11
|
type ControlRequest = { type: 'deliver'; text: string }
|
|
10
12
|
type ChildTurn = { process: ChildProcess; exited: Promise<number | null> }
|
|
@@ -14,8 +16,6 @@ const SPEX = join(PKG, 'bin', 'spex.mjs')
|
|
|
14
16
|
const CONTROL_TIMEOUT_MS = 30_000
|
|
15
17
|
const START_TIMEOUT_MS = 30_000
|
|
16
18
|
|
|
17
|
-
const shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
18
|
-
|
|
19
19
|
/** The resident controller socket is distinct from pi's per-turn rendezvous socket. */
|
|
20
20
|
export const piHeadlessSock = (id: string) => join(tmpdir(), `spexcode-ph-${id}.sock`)
|
|
21
21
|
|
|
@@ -23,53 +23,11 @@ export function piHeadlessLaunchCommand(id: string, runtimeDir: string, piCmd: s
|
|
|
23
23
|
return [shQuote(SPEX), 'internal', 'pi-headless-run', shQuote(id), shQuote(runtimeDir), shQuote(piCmd), '--'].join(' ')
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
|
|
27
|
-
return new Promise((resolve, reject) => {
|
|
28
|
-
const timer = setTimeout(() => reject(new Error(message)), ms)
|
|
29
|
-
promise.then(
|
|
30
|
-
(value) => { clearTimeout(timer); resolve(value) },
|
|
31
|
-
(error) => { clearTimeout(timer); reject(error) },
|
|
32
|
-
)
|
|
33
|
-
})
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function controlRequest(id: string, request: ControlRequest): Promise<DispatchResult> {
|
|
37
|
-
return new Promise((resolve) => {
|
|
38
|
-
let socket: Socket | undefined
|
|
39
|
-
let settled = false
|
|
40
|
-
let buffer = ''
|
|
41
|
-
const finish = (result: DispatchResult) => {
|
|
42
|
-
if (settled) return
|
|
43
|
-
settled = true
|
|
44
|
-
clearTimeout(timer)
|
|
45
|
-
socket?.destroy()
|
|
46
|
-
resolve(result)
|
|
47
|
-
}
|
|
48
|
-
const timer = setTimeout(() => finish({ ok: false, error: `pi-headless control timed out for session ${id}` }), CONTROL_TIMEOUT_MS)
|
|
49
|
-
try { socket = createConnection(piHeadlessSock(id)) } catch (error) {
|
|
50
|
-
finish({ ok: false, error: `pi-headless controller connect failed for session ${id}: ${(error as Error).message}` })
|
|
51
|
-
return
|
|
52
|
-
}
|
|
53
|
-
socket.setEncoding('utf8')
|
|
54
|
-
socket.on('connect', () => socket!.write(`${JSON.stringify(request)}\n`))
|
|
55
|
-
socket.on('data', (chunk) => {
|
|
56
|
-
buffer += chunk
|
|
57
|
-
const nl = buffer.indexOf('\n')
|
|
58
|
-
if (nl < 0) return
|
|
59
|
-
try {
|
|
60
|
-
const response = JSON.parse(buffer.slice(0, nl)) as DispatchResult
|
|
61
|
-
finish(response.ok ? { ok: true } : { ok: false, error: response.error || 'pi-headless controller rejected the request' })
|
|
62
|
-
} catch (error) {
|
|
63
|
-
finish({ ok: false, error: `pi-headless returned an invalid control response: ${(error as Error).message}` })
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
|
-
socket.on('error', (error) => finish({ ok: false, error: `pi-headless controller unreachable for session ${id}: ${error.message}` }))
|
|
67
|
-
socket.on('close', () => finish({ ok: false, error: `pi-headless controller closed before confirming session ${id}` }))
|
|
68
|
-
})
|
|
69
|
-
}
|
|
70
|
-
|
|
71
26
|
export const deliverViaPiHeadless = (rec: HarnessDeliveryRecord, text: string) =>
|
|
72
|
-
controlRequest(rec.session, { type: 'deliver', text }
|
|
27
|
+
controlRequest(piHeadlessSock(rec.session), { type: 'deliver', text }, {
|
|
28
|
+
name: 'pi-headless', session: rec.session, timeoutMs: CONTROL_TIMEOUT_MS,
|
|
29
|
+
rejected: 'pi-headless controller rejected the request',
|
|
30
|
+
})
|
|
73
31
|
|
|
74
32
|
export class PiHeadlessController {
|
|
75
33
|
private server: Server | null = null
|
|
@@ -111,9 +69,10 @@ export class PiHeadlessController {
|
|
|
111
69
|
if (!this.server) return resolve()
|
|
112
70
|
this.server.close(() => resolve())
|
|
113
71
|
})
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
72
|
+
// same proof-before-removal rule as every other teardown (harness.ts unlinkSocks): a socket path is keyed
|
|
73
|
+
// by session id alone, so only a listener PROVEN dead is ours to unlink.
|
|
74
|
+
const { rvSock, unlinkSocks } = await import('./harness.js')
|
|
75
|
+
await unlinkSocks(this.socketPath, rvSock(this.id))
|
|
117
76
|
}
|
|
118
77
|
|
|
119
78
|
private accept(socket: Socket): void {
|
|
@@ -144,14 +103,12 @@ export class PiHeadlessController {
|
|
|
144
103
|
|
|
145
104
|
// A live extension listener is an in-flight pi turn. The native steer path is parse-confirmed by the
|
|
146
105
|
// shared rendezvous protocol. Only a proven absent listener may cold-wake a saved session.
|
|
147
|
-
const {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
await this.spawnTurn(request.text, true)
|
|
154
|
-
return { ok: true }
|
|
106
|
+
const { deliverViaSocketOrWake } = await import('./harness.js')
|
|
107
|
+
return deliverViaSocketOrWake(this.id, request.text, async () => {
|
|
108
|
+
if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
|
|
109
|
+
await this.spawnTurn(request.text, true)
|
|
110
|
+
return { ok: true }
|
|
111
|
+
}, `could not determine whether pi turn ${this.id} is live — prompt NOT delivered`)
|
|
155
112
|
}
|
|
156
113
|
|
|
157
114
|
private async spawnTurn(text: string, resume: boolean): Promise<void> {
|
|
@@ -171,6 +128,7 @@ export class PiHeadlessController {
|
|
|
171
128
|
childProcess.once('close', (code) => {
|
|
172
129
|
if (this.child === turn) this.child = null
|
|
173
130
|
resolveExit(code)
|
|
131
|
+
if (code !== 0 && !this.closing) void import('./harness.js').then(({ reportHeadlessTurnExit }) => reportHeadlessTurnExit(this.id, 'pi-headless', code, this.cwd))
|
|
174
132
|
})
|
|
175
133
|
await withTimeout(new Promise<void>((resolve, reject) => {
|
|
176
134
|
childProcess.once('spawn', () => resolve())
|