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
@@ -1,4 +1,7 @@
1
1
  import { apiBase, assertProjectMatch, resolveSession, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
2
+ import { readSync, writeSync } from 'node:fs'
3
+ import { maintenanceBrokerDescriptors, type Capability, type LeaseOwner, type MaintenanceState } from './session-maintenance.js'
4
+ import { processStartToken } from './process-identity.js'
2
5
 
3
6
  export class BackendError extends Error {
4
7
  constructor(message: string, readonly status?: number) {
@@ -10,6 +13,8 @@ export class BackendError extends Error {
10
13
  // the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
11
14
  // is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
12
15
  async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
16
+ if (maintenanceBrokerDescriptors() && init?.method && !['GET', 'HEAD'].includes(init.method.toUpperCase()))
17
+ throw new BackendError('maintenance_capability_missing: the operator broker admits only its exact stop/resume plan')
13
18
  const base = await apiBase()
14
19
  try {
15
20
  return await fetch(`${base}${path}`, init)
@@ -23,17 +28,127 @@ async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
23
28
  const guarded = (verb: string) => assertProjectMatch(`spex ${verb}`)
24
29
  const post = (body: unknown): RequestInit => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
25
30
  const seg = (id: string) => encodeURIComponent(id)
31
+ type BrokerRequest = { op: string; sessionId?: string; force?: boolean }
32
+ type BrokerOutcome = 'committed' | 'refused' | 'indeterminate'
33
+ type BrokerResponse = { ok: boolean; outcome?: BrokerOutcome; status?: number; body?: any; code?: string; error?: string }
34
+ function brokerRequest(request: BrokerRequest): BrokerResponse | null {
35
+ const fds = maintenanceBrokerDescriptors()
36
+ if (!fds) return null
37
+ const startToken = processStartToken(process.pid)
38
+ if (!startToken) throw new BackendError(`maintenance broker cannot identify client PID ${process.pid}`)
39
+ const id = `${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
40
+ const frame = `${JSON.stringify({ v: 1, id, ...request, client: { pid: process.pid, startToken } })}\n`
41
+ if (Buffer.byteLength(frame) > 4_096) throw new BackendError('maintenance broker request exceeded PIPE_BUF')
42
+ const token = Buffer.alloc(1)
43
+ let ownsTurn = false
44
+ try {
45
+ if (readSync(fds.turn, token, 0, 1, null) !== 1 || token[0] !== 1)
46
+ throw new BackendError('maintenance broker turn token was unavailable or invalid')
47
+ ownsTurn = true
48
+ if (writeSync(fds.request, frame) !== Buffer.byteLength(frame)) throw new BackendError('maintenance broker request write was incomplete')
49
+ let line = ''
50
+ const byte = Buffer.alloc(1)
51
+ while (!line.endsWith('\n')) {
52
+ const count = readSync(fds.response, byte, 0, 1, null)
53
+ if (count === 0) throw new BackendError('maintenance broker closed before replying')
54
+ line += byte.toString('utf8', 0, count)
55
+ if (line.length > 64 * 1024) throw new BackendError('maintenance broker response exceeded its bound')
56
+ }
57
+ const response = JSON.parse(line) as BrokerResponse & { id?: string }
58
+ if (response.id !== id) throw new BackendError('maintenance broker response identity mismatch')
59
+ return response
60
+ } finally {
61
+ if (ownsTurn) {
62
+ try { writeSync(fds.turn, token) } catch { /* broker shutdown owns the failure */ }
63
+ }
64
+ }
65
+ }
66
+ const brokerMutation = (request: BrokerRequest): BrokerResponse | null => brokerRequest(request)
67
+
68
+ export type MaintenanceLeaseResponse = { token: string; epoch: number; state: 'draining' | 'active'; owner: LeaseOwner; capabilities?: Capability[] }
69
+ export async function clientMaintenanceAcquire(capabilities: Capability[], ttlMs: number, waitMs: number): Promise<{ status: number; lease: MaintenanceLeaseResponse }> {
70
+ await guarded('session maintain')
71
+ const r = await apiFetch('/api/session-maintenance/acquire', post({ capabilities, ttlMs, waitMs }))
72
+ const lease = await r.json().catch(() => ({})) as MaintenanceLeaseResponse & { error?: string }
73
+ if (r.status !== 201 && r.status !== 202) throw new BackendError(`backend refused maintenance acquire (${r.status}): ${lease.error || 'invalid response'}`, r.status)
74
+ return { status: r.status, lease }
75
+ }
76
+ export async function clientMaintenanceStatus(): Promise<MaintenanceState> {
77
+ const r = await apiFetch('/api/session-maintenance')
78
+ if (!r.ok) throw new BackendError(`backend refused maintenance status (${r.status})`, r.status)
79
+ return await r.json() as MaintenanceState
80
+ }
81
+ const maintenanceHeaders = (token: string): Record<string, string> => ({
82
+ 'content-type': 'application/json',
83
+ 'x-spexcode-session-maintenance': token,
84
+ })
85
+ export async function clientMaintenanceHeartbeat(token: string, epoch: number, ttlMs: number): Promise<MaintenanceState> {
86
+ const r = await apiFetch('/api/session-maintenance/heartbeat', { method: 'POST', headers: maintenanceHeaders(token), body: JSON.stringify({ epoch, ttlMs }) })
87
+ if (!r.ok) throw new BackendError(`backend refused maintenance heartbeat (${r.status}): ${await r.text()}`, r.status)
88
+ return await r.json() as MaintenanceState
89
+ }
90
+ export async function clientMaintenanceRelease(token: string, epoch: number): Promise<void> {
91
+ const r = await apiFetch('/api/session-maintenance/release', { method: 'POST', headers: maintenanceHeaders(token), body: JSON.stringify({ epoch }) })
92
+ if (!r.ok) throw new BackendError(`backend refused maintenance release (${r.status}): ${await r.text()}`, r.status)
93
+ }
94
+ export async function clientMaintenanceOperation(
95
+ token: string,
96
+ request: { op: 'stop' | 'resume'; sessionId: string; force?: boolean },
97
+ options: { signal?: AbortSignal; timeoutMs?: number } = {},
98
+ ): Promise<BrokerResponse> {
99
+ const path = `/api/sessions/${seg(request.sessionId)}/${request.op}`
100
+ const controller = new AbortController()
101
+ const abort = () => controller.abort(options.signal?.reason)
102
+ if (options.signal?.aborted) abort()
103
+ else options.signal?.addEventListener('abort', abort, { once: true })
104
+ const timer = setTimeout(() => controller.abort(new Error('maintenance operation timed out')), options.timeoutMs ?? 5_000)
105
+ timer.unref()
106
+ try {
107
+ const r = await apiFetch(path, {
108
+ method: 'POST', headers: maintenanceHeaders(token), signal: controller.signal,
109
+ body: JSON.stringify(request.op === 'resume' ? { force: request.force === true } : {}),
110
+ })
111
+ let body: any
112
+ try { body = await r.json() } catch {
113
+ return { ok: false, outcome: 'indeterminate', status: r.status, code: 'maintenance_response_malformed', error: 'maintenance operation returned malformed JSON' }
114
+ }
115
+ const committed = r.ok && body?.ok === true
116
+ const refused = (r.status >= 400 && r.status < 500 && (body?.ok === false || typeof body?.code === 'string'))
117
+ || (r.ok && body?.ok === false && body?.refused === true)
118
+ const outcome: BrokerOutcome = committed ? 'committed' : refused ? 'refused' : 'indeterminate'
119
+ const code = typeof body?.code === 'string' ? body.code
120
+ : refused ? 'session_operation_refused'
121
+ : committed ? undefined : 'maintenance_operation_indeterminate'
122
+ return {
123
+ ok: committed,
124
+ outcome,
125
+ status: r.status,
126
+ body,
127
+ ...(code ? { code } : {}),
128
+ ...(!committed ? { error: body?.error || `status ${r.status}` } : {}),
129
+ }
130
+ } finally {
131
+ clearTimeout(timer)
132
+ options.signal?.removeEventListener('abort', abort)
133
+ }
134
+ }
26
135
 
27
136
  // GET /api/sessions — the board, used by `spex session ls`, and by `spex session watch`/`wait` as their poll source.
28
- export async function clientListSessions(): Promise<Session[]> {
29
- const r = await apiFetch('/api/sessions')
137
+ export async function clientListSessions(includeArchived = false): Promise<Session[]> {
138
+ const r = await apiFetch(includeArchived ? '/api/sessions?all=1' : '/api/sessions')
30
139
  if (!r.ok) throw new BackendError(`backend error ${r.status} listing sessions`, r.status)
31
140
  return await r.json() as Session[]
32
141
  }
33
142
 
143
+ export async function clientResources(): Promise<import('./host-resources.js').ResourceReport> {
144
+ const r = await apiFetch('/api/resources')
145
+ if (!r.ok) throw new BackendError(`backend error ${r.status} loading resources: ${await r.text()}`, r.status)
146
+ return await r.json() as import('./host-resources.js').ResourceReport
147
+ }
148
+
34
149
  // resolve a selector (full id, id-prefix, node, or branch) against the live board, then call with the full id.
35
- export async function resolveClientSession(selector: string): Promise<Resolved> {
36
- return resolveSession(selector, await clientListSessions())
150
+ export async function resolveClientSession(selector: string, includeArchived = true): Promise<Resolved> {
151
+ return resolveSession(selector, await clientListSessions(includeArchived))
37
152
  }
38
153
 
39
154
  // GET /api/sessions/:id/capture — the live pane as text. The discriminated result keeps the three failure
@@ -49,6 +164,8 @@ export async function clientCapture(id: string): Promise<CaptureResult> {
49
164
  // socket, socket-only + fail-loud; a non-accepted prompt comes back ok:false / HTTP 502).
50
165
  export async function clientSend(id: string, text: string, from?: string): Promise<DispatchResult> {
51
166
  await guarded('session send')
167
+ const brokered = brokerMutation({ op: 'send', sessionId: id })
168
+ if (brokered) return brokered.ok ? brokered.body as DispatchResult : { ok: false, error: brokered.code || brokered.error || 'maintenance_active' }
52
169
  // `from` = the sending agent's own session id; the backend logs the comms edge ([[comms-edge]]) only when
53
170
  // it's present (an agent send), so a human-shell send stays unrecorded.
54
171
  const r = await apiFetch(`/api/sessions/${seg(id)}/input`, post({ kind: 'text', text, ...(from ? { from } : {}) }))
@@ -104,7 +221,7 @@ export async function clientEvals(id: string): Promise<EvalsResult> {
104
221
  }
105
222
  if (!changed) return { ok: true, model: { ...first!, id, items } }
106
223
  }
107
- throw new BackendError(`session eval pages changed while reading ${id}; retry the command`)
224
+ throw new BackendError(`session eval pages changed while fetching ${id}; retry the command`)
108
225
  }
109
226
 
110
227
  // POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
@@ -120,6 +237,11 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
120
237
  // carries a non-error advisory.
121
238
  export async function clientResume(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
122
239
  await guarded('session resume')
240
+ const brokered = brokerMutation({ op: 'resume', sessionId: id, force })
241
+ if (brokered) {
242
+ if (!brokered.ok) throw new BackendError(brokered.code || brokered.error || 'maintenance_active', brokered.status)
243
+ return brokered.body
244
+ }
123
245
  const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
124
246
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
125
247
  }
@@ -128,7 +250,13 @@ export async function clientResume(id: string, force = false): Promise<{ ok: boo
128
250
  // resumable). Distinct from close. {ok:false} = no such session.
129
251
  export async function clientStop(id: string): Promise<boolean> {
130
252
  await guarded('session stop')
253
+ const brokered = brokerMutation({ op: 'stop', sessionId: id })
254
+ if (brokered) {
255
+ if (!brokered.ok) throw new BackendError(brokered.code || brokered.error || 'maintenance_active', brokered.status)
256
+ return !!brokered.body?.ok
257
+ }
131
258
  const r = await apiFetch(`/api/sessions/${seg(id)}/stop`, post({}))
259
+ if (!r.ok) throw new BackendError(`backend refused to stop ${id}: ${await r.text()}`, r.status)
132
260
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
133
261
  }
134
262
 
@@ -144,6 +272,16 @@ export async function clientInterrupt(id: string): Promise<DispatchResult> {
144
272
  export async function clientClose(id: string): Promise<boolean> {
145
273
  await guarded('session close')
146
274
  const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({}))
275
+ if (!r.ok) throw new BackendError(`backend refused to close ${id}: ${await r.text()}`, r.status)
276
+ return !!(await r.json().catch(() => ({ ok: false })))?.ok
277
+ }
278
+
279
+ // POST /api/sessions/:id/archive — cold-archive the session ([[archive]]). The legacy on=false spelling is a
280
+ // signpost to the same resume transition; it never performs a record-only unarchive.
281
+ export async function clientArchive(id: string, on = true): Promise<boolean> {
282
+ await guarded(on ? 'session archive' : 'session unarchive')
283
+ const r = await apiFetch(`/api/sessions/${seg(id)}/archive`, post({ on }))
284
+ if (!r.ok) throw new BackendError(`backend refused to ${on ? 'archive' : 'unarchive'} ${id}: ${await r.text()}`, r.status)
147
285
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
148
286
  }
149
287
 
@@ -30,6 +30,7 @@ import { installConnectionReaper } from './reaper.js'
30
30
  import { spexcodeHome, encodeProject } from './layout.js'
31
31
  import { readEndpointRecord } from './host.js'
32
32
  import { readGatewayIdentity, type ResolvedIdentity } from './project-identity.js'
33
+ import { proxyHttp } from './gateway.js'
33
34
 
34
35
  export type HubProject = { id: string; identity: ResolvedIdentity; url: string; port: number; gated: boolean }
35
36
  // @@@ extension seam - the hub stays the ONE routing+authorization server; a host-level caller
@@ -351,10 +352,5 @@ function proxyTo(req: http.IncomingMessage, res: http.ServerResponse, upstreamPo
351
352
  const headers: http.OutgoingHttpHeaders = { ...req.headers }
352
353
  const kept = stripGatewayCookies(req.headers.cookie)
353
354
  if (kept) headers.cookie = kept; else delete headers.cookie
354
- const up = http.request({ host: '127.0.0.1', port: upstreamPort, path, method: req.method, headers }, (upRes) => {
355
- res.writeHead(upRes.statusCode || 502, upRes.headers)
356
- upRes.pipe(res)
357
- })
358
- up.on('error', () => { if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain' }); res.end('project backend unreachable') })
359
- req.pipe(up)
355
+ proxyHttp(req, res, upstreamPort, path, headers)
360
356
  }
@@ -227,19 +227,104 @@ const wantsGzip = (req: http.IncomingMessage) => /\bgzip\b/.test(String(req.head
227
227
 
228
228
  // reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
229
229
  // stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
230
- // `path` overrides the upstream path (the host gateway strips its /p/:projectId prefix); default = as-is.
231
- // Exported: the host gateway ([[host-gateway]]) proxies per-project traffic through this same function.
232
- export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number, path?: string) {
233
- const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: path ?? req.url, method: req.method, headers: req.headers }, (upRes) => {
234
- const type = String(upRes.headers['content-type'] || '')
235
- const skip = !wantsGzip(req) || upRes.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream')
236
- if (skip) { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); return }
237
- const headers = { ...upRes.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }
230
+ // `path` and `headers` optionally override routing inputs (the host gateway strips its /p/:projectId
231
+ // prefix and gateway cookies); transport ownership stays here once. Defaults pass the request through.
232
+ export function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number, path?: string, headers: http.OutgoingHttpHeaders = req.headers) {
233
+ let upstreamResponse: http.IncomingMessage | null = null
234
+ let transform: ReturnType<typeof createGzip> | null = null
235
+ let settled = false
236
+
237
+ const removeDownstreamListeners = () => {
238
+ req.off('aborted', abortFromDownstream)
239
+ req.off('error', abortFromDownstream)
240
+ req.off('end', onRequestEnd)
241
+ req.off('close', onRequestClose)
242
+ res.off('error', abortFromDownstream)
243
+ res.off('close', onResponseClose)
244
+ res.off('finish', onResponseFinish)
245
+ }
246
+ const destroyUpstream = () => {
247
+ req.unpipe(up)
248
+ if (upstreamResponse && transform) {
249
+ upstreamResponse.unpipe(transform)
250
+ transform.unpipe(res)
251
+ transform.destroy()
252
+ } else if (upstreamResponse) {
253
+ upstreamResponse.unpipe(res)
254
+ }
255
+ upstreamResponse?.destroy()
256
+ up.destroy()
257
+ }
258
+ const settle = () => {
259
+ if (settled) return
260
+ settled = true
261
+ removeDownstreamListeners()
262
+ }
263
+ const onResponseFinish = () => {
264
+ if (settled) return
265
+ if (req.complete) { settle(); return }
266
+ // An upstream may reject a body before the client finishes sending it. The response is valid, but the
267
+ // upload leg is over: sever it upstream and drain the downstream body so the flushed response and
268
+ // keep-alive socket are not turned into a reset. Request abort ownership stays until end/close.
269
+ req.unpipe(up)
270
+ upstreamResponse?.destroy()
271
+ up.socket?.destroy()
272
+ up.destroy()
273
+ if (!req.destroyed) req.resume()
274
+ }
275
+ const abortFromDownstream = () => {
276
+ if (settled) return
277
+ settled = true
278
+ removeDownstreamListeners()
279
+ destroyUpstream()
280
+ }
281
+ const onRequestClose = () => {
282
+ if (!req.complete) abortFromDownstream()
283
+ else if (res.writableFinished) settle()
284
+ }
285
+ const onRequestEnd = () => { if (res.writableFinished) settle() }
286
+ const onResponseClose = () => {
287
+ if (!res.writableFinished) abortFromDownstream()
288
+ }
289
+ const failFromUpstream = () => {
290
+ if (settled) return
291
+ settled = true
292
+ removeDownstreamListeners()
293
+ destroyUpstream()
294
+ if (res.destroyed) return
295
+ if (!res.headersSent) { res.writeHead(502); res.end('upstream unreachable') }
296
+ else res.destroy()
297
+ }
298
+
299
+ const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: path ?? req.url, method: req.method, headers }, (received) => {
300
+ if (settled || res.destroyed) { received.destroy(); up.destroy(); return }
301
+ upstreamResponse = received
302
+ received.once('aborted', failFromUpstream)
303
+ received.once('error', failFromUpstream)
304
+ received.once('close', () => { if (!received.complete) failFromUpstream() })
305
+
306
+ const type = String(received.headers['content-type'] || '')
307
+ const skip = !wantsGzip(req) || received.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream')
308
+ if (skip) {
309
+ res.writeHead(received.statusCode || 502, received.headers)
310
+ received.pipe(res)
311
+ return
312
+ }
313
+ const headers = { ...received.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }
238
314
  delete headers['content-length'] // streamed; the encoded length isn't knowable up front
239
- res.writeHead(upRes.statusCode || 502, headers)
240
- upRes.pipe(createGzip()).pipe(res)
315
+ res.writeHead(received.statusCode || 502, headers)
316
+ transform = createGzip()
317
+ transform.once('error', failFromUpstream)
318
+ received.pipe(transform).pipe(res)
241
319
  })
242
- up.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('upstream unreachable') })
320
+ up.once('error', failFromUpstream)
321
+ req.once('aborted', abortFromDownstream)
322
+ req.once('error', abortFromDownstream)
323
+ req.once('end', onRequestEnd)
324
+ req.once('close', onRequestClose)
325
+ res.once('error', abortFromDownstream)
326
+ res.once('close', onResponseClose)
327
+ res.once('finish', onResponseFinish)
243
328
  req.pipe(up)
244
329
  }
245
330