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.
Files changed (155) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/spec-cli/bin/spex.mjs +25 -1
  4. package/spec-cli/hooks/dispatch.sh +24 -1
  5. package/spec-cli/src/anchors.ts +192 -58
  6. package/spec-cli/src/attach.ts +11 -8
  7. package/spec-cli/src/claude-headless.ts +116 -66
  8. package/spec-cli/src/cli.ts +221 -72
  9. package/spec-cli/src/client.ts +143 -5
  10. package/spec-cli/src/gateway-hub.ts +2 -6
  11. package/spec-cli/src/gateway.ts +96 -11
  12. package/spec-cli/src/git.ts +1395 -96
  13. package/spec-cli/src/graph.ts +21 -17
  14. package/spec-cli/src/graphCache.ts +405 -61
  15. package/spec-cli/src/graphStream.ts +581 -182
  16. package/spec-cli/src/guide.ts +13 -3
  17. package/spec-cli/src/harness.ts +965 -70
  18. package/spec-cli/src/headless-controller.ts +63 -0
  19. package/spec-cli/src/help.ts +120 -64
  20. package/spec-cli/src/host-resources.ts +706 -0
  21. package/spec-cli/src/host.ts +1 -0
  22. package/spec-cli/src/index.ts +95 -21
  23. package/spec-cli/src/init.ts +47 -8
  24. package/spec-cli/src/issues.ts +36 -3
  25. package/spec-cli/src/layout.ts +193 -37
  26. package/spec-cli/src/lint.ts +152 -27
  27. package/spec-cli/src/localIssues.ts +5 -1
  28. package/spec-cli/src/maintenance-wrapper.ts +276 -0
  29. package/spec-cli/src/materialize.ts +6 -7
  30. package/spec-cli/src/opencode-headless.ts +117 -7
  31. package/spec-cli/src/opencode.ts +8 -4
  32. package/spec-cli/src/pi-headless.ts +18 -60
  33. package/spec-cli/src/process-identity.ts +49 -0
  34. package/spec-cli/src/project-store.ts +17 -0
  35. package/spec-cli/src/pty-bridge.ts +37 -7
  36. package/spec-cli/src/pty-helper.mjs +16 -6
  37. package/spec-cli/src/pty-native-helper.mjs +22 -0
  38. package/spec-cli/src/reviews.ts +17 -13
  39. package/spec-cli/src/runtime-ownership.ts +99 -0
  40. package/spec-cli/src/session-maintenance.ts +750 -0
  41. package/spec-cli/src/session-timeline.ts +29 -23
  42. package/spec-cli/src/sessions.ts +1262 -200
  43. package/spec-cli/src/sh.ts +3 -0
  44. package/spec-cli/src/source-files.ts +27 -2
  45. package/spec-cli/src/specs.ts +58 -25
  46. package/spec-cli/src/supervise.ts +31 -11
  47. package/spec-cli/src/uninstall.ts +7 -6
  48. package/spec-cli/templates/hooks/commit-msg +14 -0
  49. package/spec-cli/templates/hooks/pre-commit +26 -6
  50. package/spec-cli/templates/hooks/prepare-commit-msg +20 -48
  51. package/spec-cli/templates/hooks/reference-transaction +105 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +23 -21
  54. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -10
  55. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +15 -6
  56. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +32 -0
  57. package/spec-dashboard/dist/assets/App-C5vbTw8Q.js +2 -0
  58. package/spec-dashboard/dist/assets/Dashboard-u8RIS3NY.js +27 -0
  59. package/spec-dashboard/dist/assets/EvalsPage-BS7ITcNo.js +2 -0
  60. package/spec-dashboard/dist/assets/IssuesPage-DXbqQFW_.js +1 -0
  61. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  62. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  63. package/spec-dashboard/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  64. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  65. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  66. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  67. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  68. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  69. package/spec-dashboard/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  70. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  71. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  72. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  73. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  74. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  75. package/spec-dashboard/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  76. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  77. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  78. package/spec-dashboard/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  79. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  80. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  81. package/spec-dashboard/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  82. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  83. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  84. package/spec-dashboard/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  85. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  86. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  87. package/spec-dashboard/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  88. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  89. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  90. package/spec-dashboard/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  91. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  92. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  93. package/spec-dashboard/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  94. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  95. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  96. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  97. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  98. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  99. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  100. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  101. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  102. package/spec-dashboard/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  103. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  104. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  105. package/spec-dashboard/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  106. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  107. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  108. package/spec-dashboard/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  109. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  110. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  111. package/spec-dashboard/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  112. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  113. package/spec-dashboard/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  114. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  115. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  116. package/spec-dashboard/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  117. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  118. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  119. package/spec-dashboard/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  120. package/spec-dashboard/dist/assets/MobileApp-DVLnk9hz.js +2 -0
  121. package/spec-dashboard/dist/assets/Modal-6mHq6fbZ.js +1 -0
  122. package/spec-dashboard/dist/assets/{PageScroll-B_dKCuXx.js → PageScroll-CAY4S4g4.js} +1 -1
  123. package/spec-dashboard/dist/assets/ProjectsPage-UQyzsTWN.js +1 -0
  124. package/spec-dashboard/dist/assets/SessionInterface-DKU4c1Z-.js +39 -0
  125. package/spec-dashboard/dist/assets/SessionWindow-zGwJaGbR.js +1 -0
  126. package/spec-dashboard/dist/assets/Settings-igR17pns.js +1 -0
  127. package/spec-dashboard/dist/assets/Thread-B-ZUarN1.js +13 -0
  128. package/spec-dashboard/dist/assets/TimelineChat-Cp579UoJ.css +1 -0
  129. package/spec-dashboard/dist/assets/TimelineChat-sc49Qj5d.js +276 -0
  130. package/spec-dashboard/dist/assets/data-B1ot4PF0.js +1 -0
  131. package/spec-dashboard/dist/assets/index-BqBNCa1V.js +41 -0
  132. package/spec-dashboard/dist/assets/index-CzutlTDf.css +1 -0
  133. package/spec-dashboard/dist/assets/{terminalFont-DmTPNdbU.js → terminalFont-C6oGNKrf.js} +1 -1
  134. package/spec-dashboard/dist/index.html +5 -4
  135. package/spec-dashboard/src/reviewFilters.js +46 -8
  136. package/spec-dashboard/src/session.js +64 -9
  137. package/spec-eval/src/cli.ts +144 -32
  138. package/spec-eval/src/evaltab.ts +16 -9
  139. package/spec-eval/src/freshness.ts +336 -54
  140. package/spec-eval/src/scenariofresh.ts +51 -1
  141. package/spec-eval/src/scenarios.ts +324 -25
  142. package/spec-eval/src/sessioneval.ts +969 -173
  143. package/spec-cli/src/message-stream.ts +0 -147
  144. package/spec-dashboard/dist/assets/Dashboard-C_w_wdk5.js +0 -27
  145. package/spec-dashboard/dist/assets/EvalsPage-5_nfIYll.js +0 -2
  146. package/spec-dashboard/dist/assets/IssuesPage-By-u--95.js +0 -1
  147. package/spec-dashboard/dist/assets/MobileApp-CVEwjHr9.js +0 -2
  148. package/spec-dashboard/dist/assets/Modal-BqgvzMJD.js +0 -1
  149. package/spec-dashboard/dist/assets/ProjectsPage-RVP8AqK4.js +0 -1
  150. package/spec-dashboard/dist/assets/SessionInterface-Bh3vq8SU.js +0 -39
  151. package/spec-dashboard/dist/assets/SessionWindow-BuJ5mzjC.js +0 -13
  152. package/spec-dashboard/dist/assets/Settings-B8KFocsz.js +0 -1
  153. package/spec-dashboard/dist/assets/TimelineChat-K0wdlweB.js +0 -1
  154. package/spec-dashboard/dist/assets/index-BKaTHjmU.js +0 -41
  155. 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
- // tracked docs guide (docs/AGENT_GUIDE.md) FOLLOWED BY the surface:system bodies — written WHOLE into each
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 tracked docs guide (the hand-written agent/contributor notes the ONE piece of
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
- const guidePath = join(proj, 'docs', 'AGENT_GUIDE.md')
199
- const guide = existsSync(guidePath) ? readFileSync(guidePath, 'utf8').trim() : ''
200
- const systemBodies = loadSystemConfig().map((c) => c.body.trim()).filter(Boolean)
201
- const contract = [guide, ...systemBodies].filter(Boolean).join('\n\n')
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 { DispatchResult, HarnessDeliveryRecord } from './harness.js'
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 shQuote = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
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
- '__spex_run() { env "${__spex_env[@]}" "${__spex_cmd[0]}" run "${__spex_cmd[@]:1}" "$@"; }',
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(opencodeCmd: string, harnessSessionId: string | null | undefined, text: string): string {
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
  }
@@ -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 stop-gate loop closes in-process via the runtime's dispatchStop: stop_hook_active rides the
124
- // payload (the gate's loop-termination bit) and the gate's parsed reason re-enters as a follow-up
125
- // prompt; an inject the host can no longer take is reported loud.
126
- await rt.dispatchStop((reason) => injectPrompt(reason), "blocked by a spexcode hook")
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 { createConnection, createServer, type Server, type Socket } from 'node:net'
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
- try { rmSync(this.socketPath, { force: true }) } catch { /* best-effort cleanup after close */ }
115
- const { rvSock } = await import('./harness.js')
116
- try { rmSync(rvSock(this.id), { force: true }) } catch { /* pi may already have removed it */ }
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 { deliverViaRendezvous, rendezvousListening } = await import('./harness.js')
148
- const listener = await rendezvousListening(this.id)
149
- if (listener === 'live') return deliverViaRendezvous(this.id, request.text)
150
- if (listener === 'unproven') return { ok: false, error: `could not determine whether pi turn ${this.id} is live — prompt NOT delivered` }
151
-
152
- if (this.child) await withTimeout(this.child.exited, 5_000, `previous pi-headless turn did not exit for session ${this.id}`)
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())