@spexcode/spec-cli 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (201) hide show
  1. package/README.md +95 -0
  2. package/bin/spex.mjs +76 -0
  3. package/dist/attach.d.ts +2 -0
  4. package/dist/attach.js +48 -0
  5. package/dist/claude-headless.d.ts +33 -0
  6. package/dist/claude-headless.js +307 -0
  7. package/dist/cli.d.ts +1 -0
  8. package/dist/cli.js +1859 -0
  9. package/dist/client.d.ts +111 -0
  10. package/dist/client.js +476 -0
  11. package/dist/cockpit.d.ts +7 -0
  12. package/dist/cockpit.js +26 -0
  13. package/dist/codex-headless.d.ts +1 -0
  14. package/dist/codex-headless.js +7 -0
  15. package/dist/codex-runtime-generations.d.ts +53 -0
  16. package/dist/codex-runtime-generations.js +686 -0
  17. package/dist/commit-surgery.d.ts +1 -0
  18. package/dist/commit-surgery.js +90 -0
  19. package/dist/contract-filter.d.ts +14 -0
  20. package/dist/contract-filter.js +219 -0
  21. package/dist/dashboard-assets.d.ts +6 -0
  22. package/dist/dashboard-assets.js +50 -0
  23. package/dist/delivery-queue.d.ts +23 -0
  24. package/dist/delivery-queue.js +179 -0
  25. package/dist/doctor.d.ts +9 -0
  26. package/dist/doctor.js +547 -0
  27. package/dist/eval-host.d.ts +1 -0
  28. package/dist/eval-host.js +19 -0
  29. package/dist/execution-trace.d.ts +26 -0
  30. package/dist/execution-trace.js +475 -0
  31. package/dist/file-write.d.ts +2 -0
  32. package/dist/file-write.js +26 -0
  33. package/dist/flat.d.ts +89 -0
  34. package/dist/flat.js +1076 -0
  35. package/dist/gateway-auth.d.ts +56 -0
  36. package/dist/gateway-auth.js +168 -0
  37. package/dist/gateway-hub.d.ts +28 -0
  38. package/dist/gateway-hub.js +361 -0
  39. package/dist/gateway.d.ts +37 -0
  40. package/dist/gateway.js +503 -0
  41. package/dist/graphCache.d.ts +27 -0
  42. package/dist/graphCache.js +716 -0
  43. package/dist/graphSnapshot.d.ts +14 -0
  44. package/dist/graphSnapshot.js +35 -0
  45. package/dist/graphStream.d.ts +85 -0
  46. package/dist/graphStream.js +1195 -0
  47. package/dist/guidance-catalog.d.ts +64 -0
  48. package/dist/guidance-catalog.js +166 -0
  49. package/dist/guide.d.ts +8 -0
  50. package/dist/guide.js +684 -0
  51. package/dist/harness-select.d.ts +18 -0
  52. package/dist/harness-select.js +62 -0
  53. package/dist/harness.d.ts +325 -0
  54. package/dist/harness.js +3026 -0
  55. package/dist/headless-controller.d.ts +9 -0
  56. package/dist/headless-controller.js +47 -0
  57. package/dist/help.d.ts +10 -0
  58. package/dist/help.js +574 -0
  59. package/dist/hook-prompts.d.ts +18 -0
  60. package/dist/hook-prompts.js +122 -0
  61. package/dist/hooks.d.ts +1 -0
  62. package/dist/hooks.js +27 -0
  63. package/dist/host-resources.d.ts +97 -0
  64. package/dist/host-resources.js +795 -0
  65. package/dist/host.d.ts +88 -0
  66. package/dist/host.js +663 -0
  67. package/dist/index.d.ts +1 -0
  68. package/dist/index.js +899 -0
  69. package/dist/init.d.ts +1 -0
  70. package/dist/init.js +284 -0
  71. package/dist/issues-cli.d.ts +6 -0
  72. package/dist/issues-cli.js +334 -0
  73. package/dist/issues.d.ts +69 -0
  74. package/dist/issues.js +189 -0
  75. package/dist/lint.d.ts +34 -0
  76. package/dist/lint.js +476 -0
  77. package/dist/listen.d.ts +7 -0
  78. package/dist/listen.js +24 -0
  79. package/dist/localIssues.d.ts +65 -0
  80. package/dist/localIssues.js +582 -0
  81. package/dist/login-page.d.ts +5 -0
  82. package/dist/login-page.js +86 -0
  83. package/dist/loop-in.d.ts +23 -0
  84. package/dist/loop-in.js +68 -0
  85. package/dist/machine-peer.d.ts +69 -0
  86. package/dist/machine-peer.js +603 -0
  87. package/dist/materialize.d.ts +18 -0
  88. package/dist/materialize.js +468 -0
  89. package/dist/mentions.d.ts +50 -0
  90. package/dist/mentions.js +117 -0
  91. package/dist/opencode-headless.d.ts +4 -0
  92. package/dist/opencode-headless.js +208 -0
  93. package/dist/opencode.d.ts +3 -0
  94. package/dist/opencode.js +142 -0
  95. package/dist/pi-harness.d.ts +3 -0
  96. package/dist/pi-harness.js +129 -0
  97. package/dist/pi-headless.d.ts +25 -0
  98. package/dist/pi-headless.js +196 -0
  99. package/dist/plugin-harness.d.ts +21 -0
  100. package/dist/plugin-harness.js +146 -0
  101. package/dist/pty-bridge.d.ts +10 -0
  102. package/dist/pty-bridge.js +404 -0
  103. package/dist/pty-helper.d.mts +1 -0
  104. package/dist/pty-helper.mjs +101 -0
  105. package/dist/pty-native-helper.d.mts +2 -0
  106. package/dist/pty-native-helper.mjs +23 -0
  107. package/dist/public-graph.d.ts +46 -0
  108. package/dist/public-graph.js +56 -0
  109. package/dist/ranker.d.ts +15 -0
  110. package/dist/ranker.js +188 -0
  111. package/dist/reaper.d.ts +6 -0
  112. package/dist/reaper.js +81 -0
  113. package/dist/reviews.d.ts +189 -0
  114. package/dist/reviews.js +350 -0
  115. package/dist/runtime-guard.d.ts +6 -0
  116. package/dist/runtime-guard.js +37 -0
  117. package/dist/runtime-ownership.d.ts +21 -0
  118. package/dist/runtime-ownership.js +84 -0
  119. package/dist/search.bench.d.mts +1 -0
  120. package/dist/search.bench.mjs +75 -0
  121. package/dist/search.d.ts +20 -0
  122. package/dist/search.js +61 -0
  123. package/dist/session-cursors.d.ts +14 -0
  124. package/dist/session-cursors.js +82 -0
  125. package/dist/session-declarations.d.ts +13 -0
  126. package/dist/session-declarations.js +126 -0
  127. package/dist/session-execution.d.ts +10 -0
  128. package/dist/session-execution.js +70 -0
  129. package/dist/session-files.d.ts +28 -0
  130. package/dist/session-files.js +130 -0
  131. package/dist/session-follow.d.ts +31 -0
  132. package/dist/session-follow.js +179 -0
  133. package/dist/session-reparent.d.ts +10 -0
  134. package/dist/session-reparent.js +33 -0
  135. package/dist/session-timeline.d.ts +51 -0
  136. package/dist/session-timeline.js +233 -0
  137. package/dist/session-web.d.ts +24 -0
  138. package/dist/session-web.js +141 -0
  139. package/dist/sessions.d.ts +412 -0
  140. package/dist/sessions.js +4239 -0
  141. package/dist/sh.d.ts +1 -0
  142. package/dist/sh.js +3 -0
  143. package/dist/shim-runtime.d.ts +1 -0
  144. package/dist/shim-runtime.js +161 -0
  145. package/dist/slash-commands.d.ts +9 -0
  146. package/dist/slash-commands.js +307 -0
  147. package/dist/source-files.d.ts +9 -0
  148. package/dist/source-files.js +113 -0
  149. package/dist/supervise.d.ts +1 -0
  150. package/dist/supervise.js +308 -0
  151. package/dist/tree.d.ts +25 -0
  152. package/dist/tree.js +98 -0
  153. package/dist/tsx-bin.d.ts +3 -0
  154. package/dist/tsx-bin.js +41 -0
  155. package/dist/uninstall.d.ts +3 -0
  156. package/dist/uninstall.js +177 -0
  157. package/dist/uploads.d.ts +36 -0
  158. package/dist/uploads.js +243 -0
  159. package/dist/worktree-sources.d.ts +1 -0
  160. package/dist/worktree-sources.js +47 -0
  161. package/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  162. package/hooks/compat/mark-active-sed-v0.fixture +46 -0
  163. package/hooks/dispatch.sh +105 -0
  164. package/hooks/harness.sh +272 -0
  165. package/package.json +47 -0
  166. package/templates/hooks/commit-msg +14 -0
  167. package/templates/hooks/post-checkout +25 -0
  168. package/templates/hooks/post-merge +34 -0
  169. package/templates/hooks/pre-commit +120 -0
  170. package/templates/hooks/prepare-commit-msg +35 -0
  171. package/templates/hooks/reference-transaction +105 -0
  172. package/templates/spec/project/.plugins/commands/extract/spec.md +66 -0
  173. package/templates/spec/project/.plugins/commands/regroup/spec.md +25 -0
  174. package/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  175. package/templates/spec/project/.plugins/commands/spec.md +30 -0
  176. package/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  177. package/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  178. package/templates/spec/project/.plugins/core/comment-altitude/spec.md +9 -0
  179. package/templates/spec/project/.plugins/core/idle/idle.sh +15 -0
  180. package/templates/spec/project/.plugins/core/idle/spec.md +13 -0
  181. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +59 -0
  182. package/templates/spec/project/.plugins/core/mark-active/spec.md +22 -0
  183. package/templates/spec/project/.plugins/core/session-fail/fail.sh +12 -0
  184. package/templates/spec/project/.plugins/core/session-fail/spec.md +15 -0
  185. package/templates/spec/project/.plugins/core/spec-first/spec-first.sh +43 -0
  186. package/templates/spec/project/.plugins/core/spec-first/spec.md +17 -0
  187. package/templates/spec/project/.plugins/core/spec-of-file/spec-of-file.sh +66 -0
  188. package/templates/spec/project/.plugins/core/spec-of-file/spec.md +15 -0
  189. package/templates/spec/project/.plugins/core/spec.md +22 -0
  190. package/templates/spec/project/.plugins/core/stop-gate/spec.md +23 -0
  191. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +149 -0
  192. package/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +15 -0
  193. package/templates/spec/project/.plugins/prompts/forge-link/spec.md +10 -0
  194. package/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +13 -0
  195. package/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +12 -0
  196. package/templates/spec/project/.plugins/prompts/spec.md +24 -0
  197. package/templates/spec/project/.plugins/skills/distill/digest.mjs +136 -0
  198. package/templates/spec/project/.plugins/skills/distill/spec.md +67 -0
  199. package/templates/spec/project/.plugins/skills/spec.md +19 -0
  200. package/templates/spec/project/.plugins/spec.md +23 -0
  201. package/templates/spec/project/spec.md +20 -0
package/dist/index.js ADDED
@@ -0,0 +1,899 @@
1
+ import { serve } from '@hono/node-server';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { createReadStream } from 'node:fs';
4
+ import { Readable } from 'node:stream';
5
+ import { installConnectionReaper } from './reaper.js';
6
+ import { Hono } from 'hono';
7
+ import { cors } from 'hono/cors';
8
+ import { etag } from 'hono/etag';
9
+ import { createNodeWebSocket } from '@hono/node-ws';
10
+ import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig } from '@spexcode/spec-core';
11
+ import { issuesEnabled, resolveRemark, retractRemark } from './localIssues.js';
12
+ import { closeIssue, createIssue, findIssue, mergedIssues, promote } from './issues.js';
13
+ import { remarkWithLoopIn, replyIssueWithLoopIn } from './loop-in.js';
14
+ import { residentForgeState, refreshForgeNow } from '@spexcode/spec-forge/resident';
15
+ import { resolveForgeHost } from '@spexcode/spec-forge/drivers';
16
+ import { dispatchNewMentions, summarizeDispatch, summarizeLoopIn } from './mentions.js';
17
+ import { resolveLayout, mainBranch } from '@spexcode/spec-core';
18
+ import { getBoardJson } from './graphCache.js';
19
+ import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged, flushDeferredWorktreeRegistryChange } from './graphStream.js';
20
+ import { gitA, gitTry, repoRoot } from '@spexcode/spec-core';
21
+ import { cockpitReview } from './cockpit.js';
22
+ import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, findSessionClosure, renameSession, setSessionSort, linkZCodeChildSession, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js';
23
+ import { readTimeline } from './session-timeline.js';
24
+ import { readSessionExecution, sessionExecutionStream } from './session-execution.js';
25
+ import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js';
26
+ import { readBlobByHash } from '@spexcode/spec-eval/evaltab';
27
+ import { putBlob } from '@spexcode/spec-eval/cache';
28
+ import { fileHumanReading } from '@spexcode/spec-eval/filing';
29
+ import { fileHumanOk } from '@spexcode/spec-eval/humanok';
30
+ import { buildExportModel, renderExportHtml, buildSessionEvals, SessionEvalUnavailableError } from '@spexcode/spec-eval/sessioneval';
31
+ import { appendUpload, cancelUpload, completeUpload, createUpload, evidenceMaxBytes, startUploadReaper, UploadError, uploadStatus } from './uploads.js';
32
+ import { listSessionFiles, openSessionFile, SESSION_FILE_PREVIEW_MAX_BYTES, sessionFilePreviewKind, SessionFileError } from './session-files.js';
33
+ import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges } from './pty-bridge.js';
34
+ import { installProcessGuards } from '@spexcode/spec-core';
35
+ import { resolveProjectIdentity } from '@spexcode/spec-core';
36
+ import { evalDetailReview, evalsReview, issuesReview } from './reviews.js';
37
+ import { collectResourceReport, ResourceConflict } from './host-resources.js';
38
+ import { reparentRequest, SessionReparentRequestError } from './session-reparent.js';
39
+ import { buildGuidanceCatalog } from './guidance-catalog.js';
40
+ import { installEvalHost } from './eval-host.js';
41
+ installEvalHost();
42
+ // last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
43
+ // self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
44
+ installProcessGuards();
45
+ const app = new Hono();
46
+ startUploadReaper();
47
+ app.use('/api/*', cors());
48
+ app.onError((error, c) => {
49
+ if (error instanceof SessionEvalUnavailableError)
50
+ return c.json({ error: error.message }, 503);
51
+ // a record that cannot carry state is a CONFLICT with the caller's request, not a server fault: the refusal
52
+ // is deliberate and already carries its own diagnosis + repair ([[sessions-core]]). Answering 500 with a
53
+ // stack would hide exactly the sentence the human needs.
54
+ if (error instanceof SessionRecordUnusable)
55
+ return c.json({ error: error.message, code: error.code }, 409);
56
+ if (error instanceof ResourceConflict)
57
+ return c.json({ error: error.message, code: error.code }, 409);
58
+ if (error instanceof SessionReparentRequestError)
59
+ return c.json({ error: error.message }, 400);
60
+ console.error(error);
61
+ return c.text('Internal Server Error', 500);
62
+ });
63
+ const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
64
+ app.get('/', (c) => c.text('spec-cli — GET /api/graph · /api/specs · /api/specs/:id/history · /api/settings · /api/sessions · /api/resources · /api/slash-commands'));
65
+ // the supervisor's readiness gate (supervise.ts): a bare git-free 200 so a booting child reports ready the
66
+ // instant Hono is listening. Not under /api/* — loopback-only (supervisor→child), no CORS needed.
67
+ app.get('/health', (c) => c.text('ok'));
68
+ // @@@ instance identity - who THIS backend is: the serve generation's instanceId (minted by the supervisor,
69
+ // constant across zero-downtime reloads, handed down via env) and the project root it serves. This is the
70
+ // answer the host gateway ([[host-gateway]]) compares an endpoint record against before proxying to it — a
71
+ // recycled port serving another project or a stale record fails the match instead of being routed to. Git-free
72
+ // after the first memoized resolution; a self-run child (no supervisor) answers instanceId:null, which no
73
+ // record claims, so it is simply not hosted.
74
+ const instanceStartedAt = new Date().toISOString();
75
+ app.get('/api/instance', (c) => {
76
+ const root = repoRoot();
77
+ return c.json({
78
+ instanceId: process.env.SPEXCODE_INSTANCE_ID ?? null,
79
+ root,
80
+ identity: resolveProjectIdentity(root, root),
81
+ pid: process.pid,
82
+ startedAt: instanceStartedAt,
83
+ });
84
+ });
85
+ // the assembled graph (merged tree + overlay + sessions) — the dashboard's single source. Same data
86
+ // as `spex graph --json`; the frontend only adds x/y pixels on top. Freshness is PUSH-first ([[graph-stream]]): the
87
+ // dashboard reloads on a `/api/graph/stream` event, not a tight poll, so the route is a conditional-request
88
+ // endpoint: `etag()` hashes the serialized body, and a reload whose `If-None-Match` matches gets a bodyless 304
89
+ // instead of the full transfer (~1 MB on the dogfood board — it scales with the node count). The 304 saves the
90
+ // WIRE only; the COMPUTE is saved by [[graph-cache]]: getBoard() is single-flight + cached, so a poll storm
91
+ // shares ONE build instead of each running its own — the poll-frequency cut (push channel) and the
92
+ // build-coalescing cut compound. A hard timeout bounds a wedged build to a loud 503 rather than an
93
+ // unboundedly-held connection (the wall sits well above the legitimately-several-seconds cold first build);
94
+ // a merely-slow single-flight build keeps running and caches for the next poll, while a NEVER-settling one
95
+ // is bounded by [[graph-cache]]'s own build watchdog, so the next poll retries a fresh build.
96
+ const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000);
97
+ app.get('/api/graph', etag(), async (c) => {
98
+ await ensureBoardFileWatchers();
99
+ const timeout = Symbol('timeout');
100
+ let timer;
101
+ const result = await Promise.race([
102
+ getBoardJson('stale-ok'),
103
+ new Promise((resolve) => {
104
+ timer = setTimeout(() => resolve(timeout), BOARD_TIMEOUT_MS);
105
+ timer.unref?.();
106
+ }),
107
+ ]);
108
+ clearTimeout(timer);
109
+ if (result === timeout)
110
+ return c.json({ error: 'graph build timed out' }, 503);
111
+ const freshness = result.refreshing ? `${result.freshness}, refreshing` : result.freshness;
112
+ c.header('x-spexcode-graph', freshness);
113
+ return c.body(result.json, 200, { 'content-type': 'application/json; charset=UTF-8' });
114
+ });
115
+ // the graph's push channel: an SSE that fires `board-changed` on any session-store write, so the dashboard
116
+ // reloads the instant status moves instead of waiting for its slow fallback poll ([[graph-stream]]).
117
+ app.get('/api/graph/stream', (c) => boardStream(c));
118
+ app.get('/api/specs', async (c) => c.json(await loadSpecs()));
119
+ // the search corpus ([[graph-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
120
+ // board omits `body` to stay lean, so the search palette fetches this ONCE when it opens (cached client-side)
121
+ // to rank nodes over their prose — off the board's hot poll. Review rows, including scenarios, come only
122
+ // from their paged endpoints and cannot be reconstructed from this corpus.
123
+ app.get('/api/specs/lite', (c) => c.json(loadSpecsLite()));
124
+ // one node's body + parsed parts ([[graph-lean]]): the board no longer ships either, so the detail view
125
+ // fetches this when a node opens. 404 for an unknown id.
126
+ app.get('/api/specs/:id/content', (c) => {
127
+ const x = specContent(c.req.param('id'));
128
+ return x ? c.json(x) : c.json({ body: '', parts: null }, 404);
129
+ });
130
+ app.get('/api/specs/:id/history', async (c) => c.json(await specHistory(c.req.param('id'))));
131
+ // the spec.md line diff one version introduced — the history tab's per-version proof-of-change, fetched
132
+ // lazily when an older version's item expands (the latest version's diff ships with the board as node.lastDiff).
133
+ app.get('/api/specs/:id/diff/:hash', async (c) => c.json(await specDiffAt(c.req.param('id'), c.req.param('hash'))));
134
+ // a unified diff of a node's spec.md from its fork point (the worktree's merge-base with main) to that
135
+ // worktree's working tree. An untracked brand-new node is invisible to `git diff <base>`, so when the base
136
+ // diff is empty AND status is `??` synthesize an all-additions view via `diff --no-index` (gitTry — --no-index
137
+ // exits 1, which gitA would swallow). Gated on `??` so a tracked file with no pending change stays empty.
138
+ app.get('/api/edit', async (c) => {
139
+ const source = c.req.query('source') || '', path = c.req.query('path') || '';
140
+ if (!source || !path)
141
+ return c.json({ patch: '' });
142
+ const mb = mainBranch();
143
+ const base = (await gitA(['-C', source, 'merge-base', mb, 'HEAD'])).trim() || mb;
144
+ let patch = await gitA(['-C', source, 'diff', base, '--', path]);
145
+ if (!patch) {
146
+ const status = await gitA(['-C', source, 'status', '--porcelain', '--untracked-files=all', '--', path]);
147
+ if (status.startsWith('??'))
148
+ patch = (await gitTry(['-C', source, 'diff', '--no-index', '--', '/dev/null', path])).stdout;
149
+ }
150
+ return c.json({ patch });
151
+ });
152
+ // the eval seam's WRITE half over HTTP ([[spec-eval]] filing.ts): a
153
+ // programmatic caller files a reading (verdict + optional transcript) through the SAME append the CLI
154
+ // uses. The dashboard does not call this — [[event-detail]] reads readings and hosts remarks, never files.
155
+ app.post('/api/specs/:id/evals', async (c) => {
156
+ const b = await c.req.json().catch(() => null);
157
+ if (!b || typeof b.scenario !== 'string')
158
+ return c.json({ error: 'body needs { scenario, status, note?, transcript? }' }, 400);
159
+ const r = fileHumanReading(c.req.param('id'), b);
160
+ return r.ok ? c.json({ ok: true, reading: r.reading }) : c.json({ error: r.error }, 400);
161
+ });
162
+ // the HUMAN SIGN-OFF write ([[human-ok]]) — the dashboard's ok affordance and `spex eval ok` share this ONE
163
+ // write (LAW L: no dashboard-only path). Identity is SERVER-DERIVED 'human', never the request body (the
164
+ // same rule as /api/remarks). The write appends a monotonic human-ok event bound to the scenario's latest
165
+ // reading and — on the trunk checkout — commits it straight to trunk; the board cache is invalidated
166
+ // atomically with persistence so the writer's own refetch never races a stale cache.
167
+ app.post('/api/specs/:id/evals/ok', async (c) => {
168
+ const b = await c.req.json().catch(() => null);
169
+ if (!b || typeof b.scenario !== 'string')
170
+ return c.json({ error: 'body needs { scenario }' }, 400);
171
+ const r = fileHumanOk(c.req.param('id'), b.scenario, 'human');
172
+ if (!r.ok)
173
+ return c.json({ error: r.error }, 400);
174
+ notifyBoardChanged('full');
175
+ return c.json({ ok: true, already: r.already, humanOk: r.humanOk });
176
+ });
177
+ // serve a reading's evidence blob by content hash (bytes never enter git): bad hash → 400, missing → 404,
178
+ // else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
179
+ // HTTP Range is honored — a <video> can only SEEK when the server answers byte ranges (a browser clamps
180
+ // currentTime to the seekable window, which stays [0,0] without them); one general mechanism at the
181
+ // transport, so every evidence kind streams the same way. A trailing `.<ext>` on the hash is IGNORED
182
+ // decoration for third-party markdown renderers (GitLab/GitHub only emit a <video> player when the URL
183
+ // ends in a video extension); the served bytes and MIME stay the stored ones — a wrong suffix never lies.
184
+ app.get('/api/evidence/:hash', (c) => {
185
+ const r = readBlobByHash(c.req.param('hash').replace(/\.[a-z0-9]+$/i, ''));
186
+ if (!r.ok)
187
+ return c.text(r.message, r.reason === 'invalid' ? 400 : 404);
188
+ const total = r.bytes.length;
189
+ const base = { 'Content-Type': r.mime, 'Cache-Control': 'public, max-age=31536000, immutable', 'Accept-Ranges': 'bytes' };
190
+ const m = /^bytes=(\d*)-(\d*)$/.exec(c.req.header('range') ?? '');
191
+ if (m && (m[1] || m[2])) {
192
+ const start = m[1] ? parseInt(m[1], 10) : total - parseInt(m[2], 10);
193
+ const end = m[1] && m[2] ? Math.min(parseInt(m[2], 10), total - 1) : total - 1;
194
+ if (!(start >= 0 && start <= end && end < total))
195
+ return c.body(null, 416, { 'Content-Range': `bytes */${total}` });
196
+ return c.body(new Uint8Array(r.bytes.subarray(start, end + 1)), 206, { ...base, 'Content-Range': `bytes ${start}-${end}/${total}` });
197
+ }
198
+ return c.body(new Uint8Array(r.bytes), 200, base);
199
+ });
200
+ // the WRITE half of the blob store ([[annotator]]): the annotator captures a circled video frame to a PNG
201
+ // and stashes the bytes here, content-addressed (same putBlob the eval cache uses). The returned hash is
202
+ // what an anchored comment references (image link in the body, and the typed evidence[] on its thread) —
203
+ // bytes never enter git. Raw body, sniffed by the same content-addressed name. Empty → 400, over cap → 413.
204
+ app.post('/api/evidence', async (c) => {
205
+ const buf = Buffer.from(await c.req.arrayBuffer());
206
+ if (buf.length === 0)
207
+ return c.json({ error: 'empty evidence' }, 400);
208
+ if (buf.length > evidenceMaxBytes())
209
+ return c.json({ error: 'evidence too large' }, 413);
210
+ return c.json({ hash: putBlob(buf) }, 201);
211
+ });
212
+ // the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
213
+ // `layout` (resolveLayout()'s main/worktrees/branch shape — the write-guard's project-identity probe reads
214
+ // `.layout.main`) and the dashboard-visible launcher profiles ([[launcher-visibility]]) the New-Session picker
215
+ // offers — `{ name, harness, cmd, headless }`: the cmd is read-only display data for the picker (the dashboard sits
216
+ // behind the gateway auth; the browser can read but never edit config) — plus the configured `default` NAME
217
+ // so the picker pre-selects the SAME launcher a bare `spex session new` uses when that row is visible, else
218
+ // its first visible row rather than a hidden headless default,
219
+ // Missing defaultLauncher is returned as an actionable config error, not hidden by falling through to the
220
+ // built-in `claude` launcher.
221
+ // `tmuxSocket` is the `-L <name>` label our private tmux server runs under (a backend fact, env-overridable),
222
+ // so the row's attach modal ([[attach-menu]]) can offer the RAW `tmux -L <socket> attach -t <id>` fallback
223
+ // beside the blessed `spex session attach` command — the frontend never hardcodes the socket.
224
+ app.get('/api/settings', async (c) => c.json({
225
+ layout: await resolveLayout(),
226
+ launchers: dashboardLauncherList(),
227
+ tmuxSocket: TMUX_SOCK,
228
+ ...launcherDefault(),
229
+ }));
230
+ // the `surface: command` plugin-root nodes (built/active only) for new-session and live-inbox `/` dropdowns — each with
231
+ // its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
232
+ // frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
233
+ // `?surface=review` lists the review-track presets instead ([[review-commands]] — the eval detail's
234
+ // remark-composer `/` dropdown); the exposed surfaces stay this explicit whitelist, never a passthrough.
235
+ app.get('/api/plugins', (c) => c.json(c.req.query('surface') === 'review' ? loadReviewConfig() : loadConfig()));
236
+ // Read-only, deterministic projection over the authoritative plugin/help/guide surfaces. The response carries
237
+ // exact rendered guidance plus provenance so decoupled consumers need no checkout or shared source directory.
238
+ app.get('/api/guidance', (c) => c.json(buildGuidanceCatalog().toJSON()));
239
+ // the ISSUES read surface ([[issues]]) for the dashboard's issues page — the merged list over every store
240
+ // (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
241
+ // (the dashboard computes nothing over it: no re-sort, no salience ranking). The `enabled` flag mirrors
242
+ // the issues-workflow on/off switch so the frontend hides the view when the feature is OFF.
243
+ app.get('/api/issues', etag(), async (c) => c.json(await issuesReview(c.req.query('q'), c.req.query('page'))));
244
+ // Evals uses the identical paged-review response. `scope:` inside q selects the worktree source; without
245
+ // it the source is the current cached board. Filtering/counts always precede the one 25-row slice.
246
+ app.get('/api/evals', etag(), async (c) => {
247
+ const scope = c.req.query('q')?.match(/(?:^|\s)scope:([^\s]+)/)?.[1];
248
+ await ensureBoardFileWatchers(scope);
249
+ const page = await evalsReview(c.req.query('q'), c.req.query('page'), { view: c.req.query('view') });
250
+ return page ? c.json(page) : c.json({ error: 'no such review source' }, 404);
251
+ });
252
+ // The exact impact graph proves a scope's membership and selector decisions. It is deliberately a named
253
+ // read: paged Evals rows retain their own reasons but never transport this scope-sized projection.
254
+ app.get('/api/evals/impact', etag(), async (c) => {
255
+ const scope = c.req.query('scope')?.trim();
256
+ if (!scope)
257
+ return c.json({ error: 'scope is required' }, 400);
258
+ await ensureBoardFileWatchers(scope);
259
+ const model = await buildSessionEvals(scope);
260
+ return model
261
+ ? c.json({ scope, impact: model.impact, evalRevision: model.evalRevision })
262
+ : c.json({ error: 'no such review source' }, 404);
263
+ });
264
+ // ONE bounded detail response for both source roots: the selected scenario's complete A/B history and at
265
+ // most five lightweight neighbors. A missing worktree scope resolves explicitly to trunk; it never
266
+ // serializes another scenario's history or the scoped model.
267
+ app.get('/api/evals/detail', etag(), async (c) => {
268
+ await ensureBoardFileWatchers(c.req.query('scope')?.trim() || undefined);
269
+ const node = c.req.query('node')?.trim();
270
+ const scenario = c.req.query('scenario')?.trim();
271
+ if (!node || !scenario)
272
+ return c.json({ error: 'node and scenario are required' }, 400);
273
+ const detail = await evalDetailReview(node, scenario, c.req.query('scope')?.trim() || null);
274
+ return c.json(detail);
275
+ });
276
+ // the single-thread read ([[issues]]) behind `spex issue show <id>` — the SAME findIssue lookup, from the
277
+ // resident forge slice (instant view, background reconcile — the list route's freshness contract). A local
278
+ // id, or a forge id (`<host>#<n>`); unknown → 404 (eval-remark threads are not issues, so they 404 here too).
279
+ app.get('/api/issues/:id', (c) => {
280
+ const t = findIssue(c.req.param('id'), { host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id));
281
+ return t ? c.json(t) : c.json({ error: `no issue '${c.req.param('id')}'` }, 404);
282
+ });
283
+ // the WRITE surface ([[local-issues]] / [[issues-view]]) — the human reply path, STORE-ROUTED through the one
284
+ // reply verb ([[issues]] replyIssue): a local id git-commits to the trunk store, a forge id ('github#N')
285
+ // posts a REAL comment through the driver. @session remains prose in either store; it never sends or spawns.
286
+ // The server owns its freshness: a forge write forces the resident slice's read-back before answering, so the
287
+ // reload that follows shows the comment. Honor the on/off switch: 403 when the feature is OFF; an unknown
288
+ // local thread → 404; a failed forge write → 502 with the driver's own message (fail loud, never queued).
289
+ app.post('/api/issues/:id/reply', async (c) => {
290
+ if (!issuesEnabled())
291
+ return c.json({ error: 'issues workflow is off' }, 403);
292
+ const body = await c.req.json().catch(() => ({}));
293
+ const text = typeof body?.body === 'string' ? body.body : '';
294
+ if (!text.trim())
295
+ return c.json({ error: 'empty reply' }, 400);
296
+ // typed evidence[] — an anchored annotation's frame-blob hashes accrue onto the local thread (same shape
297
+ // as the create route); a forge reply ignores them (its frame rides the comment body's image link).
298
+ const evidence = Array.isArray(body?.evidence) ? body.evidence.filter((h) => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)) : [];
299
+ const id = c.req.param('id');
300
+ try {
301
+ // the mention prompt's node context, from the same resident merge the GET serves
302
+ const node = id.includes('#')
303
+ ? mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id)).find((i) => i.id === id)?.nodes[0] ?? null
304
+ : null;
305
+ const r = await replyIssueWithLoopIn(id, text, { author: 'human', node, evidence });
306
+ if (r.store !== 'local')
307
+ await refreshForgeNow();
308
+ notifyBoardChanged('full'); // atomic with persistence — see the /api/remarks block below
309
+ return c.json({ ok: true, replies: r.replies, url: r.url, outcomes: [summarizeDispatch(r.outcomes), summarizeLoopIn(r.loopIn)].filter(Boolean).join(' | ') });
310
+ }
311
+ catch (e) {
312
+ const msg = String(e.message || e);
313
+ return c.json({ error: msg }, id.includes('#') ? 502 : 404);
314
+ }
315
+ });
316
+ // store-routed lifecycle close ([[issues]]): local resolves the local thread, forge closes the remote
317
+ // issue through the driver. A forge close forces read-back before the dashboard reloads the resident list.
318
+ app.post('/api/issues/:id/close', async (c) => {
319
+ if (!issuesEnabled())
320
+ return c.json({ error: 'issues workflow is off' }, 403);
321
+ const id = c.req.param('id');
322
+ try {
323
+ const r = await closeIssue(id);
324
+ if (r.store !== 'local')
325
+ await refreshForgeNow();
326
+ notifyBoardChanged('full'); // atomic with persistence — see the /api/remarks block below
327
+ return c.json({ ok: true, ...r });
328
+ }
329
+ catch (e) {
330
+ const msg = String(e.message || e);
331
+ return c.json({ error: msg }, id.includes('#') ? 502 : 404);
332
+ }
333
+ });
334
+ app.post('/api/issues', async (c) => {
335
+ if (!issuesEnabled())
336
+ return c.json({ error: 'issues workflow is off' }, 403);
337
+ const body = await c.req.json().catch(() => ({}));
338
+ const concern = typeof body?.concern === 'string' ? body.concern.trim() : '';
339
+ if (!concern)
340
+ return c.json({ error: 'empty concern' }, 400);
341
+ const nodes = Array.isArray(body?.nodes) ? body.nodes.filter((n) => typeof n === 'string') : [];
342
+ const postBody = typeof body?.body === 'string' ? body.body : undefined;
343
+ const store = typeof body?.store === 'string' && body.store.trim() ? body.store.trim() : 'local';
344
+ // typed evidence[] — content-addressed evidence hashes (the annotator's clip reference rides here, not prose)
345
+ const evidence = Array.isArray(body?.evidence) ? body.evidence.filter((h) => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)) : [];
346
+ try {
347
+ const r = await createIssue(concern, { store, nodes, body: postBody, evidence, author: 'human' });
348
+ if (r.store !== 'local')
349
+ await refreshForgeNow();
350
+ notifyBoardChanged('full'); // atomic with persistence — see the /api/remarks block below
351
+ return c.json({ ok: true, id: r.id, store: r.store, url: r.url, outcomes: summarizeDispatch(r.outcomes) }, 201);
352
+ }
353
+ catch (e) {
354
+ return c.json({ error: String(e.message || e) }, store === 'local' ? 500 : 502);
355
+ }
356
+ });
357
+ // promotion moves an open local thread to the forge as one recorded action ([[issues]]'s promote verb,
358
+ // verbatim: forge issue first, then the permalink reply + local close. The forced forge read-back means
359
+ // the reload that follows shows the promoted issue in the merged list. Fail loud: an unreachable forge is a
360
+ // 502 with the local thread untouched.
361
+ app.post('/api/issues/:id/promote', async (c) => {
362
+ if (!issuesEnabled())
363
+ return c.json({ error: 'issues workflow is off' }, 403);
364
+ const id = c.req.param('id');
365
+ if (id.includes('#'))
366
+ return c.json({ error: 'only a local issue promotes' }, 400);
367
+ try {
368
+ const r = await promote(id, { author: 'human' });
369
+ await refreshForgeNow();
370
+ notifyBoardChanged('full'); // atomic with persistence — see the /api/remarks block below
371
+ return c.json({ ok: true, ...r });
372
+ }
373
+ catch (e) {
374
+ const msg = String(e.message || e);
375
+ return c.json({ error: msg }, /^no local issue/.test(msg) ? 404 : 502);
376
+ }
377
+ });
378
+ // the REMARK write surface ([[remark-substrate]]) — server PARITY with the CLI: the dashboard can author /
379
+ // resolve / retract a remark through the SAME functions `spex remark|resolve|retract` call, adding no
380
+ // capability. A ref (`<thread-id>#<rid>`) rides the request BODY, not the path (a '#' in a URL is a
381
+ // fragment). Identity is derived SERVER-SIDE — this is the dashboard's human surface, so the actor is
382
+ // `'human'`, the SAME sentinel /api/issues stamps; it is NEVER read from the request body. That keeps R3's
383
+ // teeth structural (identity is not spoofable over the wire) and identical on both surfaces: resolve is any
384
+ // SECOND party's deliberate judgment — the human resolves an agent's remark here exactly as an agent
385
+ // resolves through the CLI, and self-resolve stays rejected by the same identity comparison ('human' can
386
+ // never resolve a human-authored remark) — and retract binds to the author (only the human's own remarks).
387
+ // Who-may-resolve/retract cannot depend on transport.
388
+ //
389
+ // Every issue/remark write route below ends its success path with notifyBoardChanged('full') — the board
390
+ // cache is invalidated ATOMICALLY with persistence ([[remark-substrate]] write-visibility), before the
391
+ // response, so the writer's own post-write refetch can never race an async fs event into the stale cache.
392
+ // This explicit nudge is the ONE in-process mechanism (the store dir is deliberately NOT in the watch set);
393
+ // a cross-process write (a CLI `spex remark add`) reaches the board through its trunk commit via the
394
+ // existing refs watcher instead.
395
+ app.post('/api/remarks', async (c) => {
396
+ if (!issuesEnabled())
397
+ return c.json({ error: 'issues workflow is off' }, 403);
398
+ const body = await c.req.json().catch(() => ({}));
399
+ const text = typeof body?.body === 'string' ? body.body : '';
400
+ if (!text.trim())
401
+ return c.json({ error: 'empty remark' }, 400);
402
+ const evidence = Array.isArray(body?.evidence) ? body.evidence.filter((h) => typeof h === 'string' && /^[0-9a-f]{64}$/.test(h)) : [];
403
+ const host = typeof body?.scenario === 'string' && body.scenario
404
+ ? { node: typeof body?.node === 'string' ? body.node : undefined, scenario: body.scenario }
405
+ : { issue: typeof body?.issue === 'string' ? body.issue : undefined };
406
+ const codeSha = typeof body?.codeSha === 'string' ? body.codeSha : undefined;
407
+ try {
408
+ const r = await remarkWithLoopIn(host, text, { codeSha, author: 'human', evidence });
409
+ notifyBoardChanged('full');
410
+ return c.json({ ok: true, ref: r.ref, rid: r.rid, codeSha: r.codeSha, outcomes: [summarizeDispatch(r.outcomes), summarizeLoopIn(r.loopIn)].filter(Boolean).join(' | ') }, 201);
411
+ }
412
+ catch (e) {
413
+ return c.json({ error: String(e.message || e) }, 400);
414
+ }
415
+ });
416
+ app.post('/api/remarks/:action{resolve|retract}', async (c) => {
417
+ if (!issuesEnabled())
418
+ return c.json({ error: 'issues workflow is off' }, 403);
419
+ const body = await c.req.json().catch(() => ({}));
420
+ const ref = typeof body?.ref === 'string' ? body.ref : '';
421
+ if (!ref)
422
+ return c.json({ error: 'missing remark ref' }, 400);
423
+ const by = 'human'; // server-derived identity — never the request body (see /api/remarks above)
424
+ try {
425
+ if (c.req.param('action') === 'resolve')
426
+ resolveRemark(ref, by);
427
+ else
428
+ retractRemark(ref, by);
429
+ notifyBoardChanged('full');
430
+ return c.json({ ok: true, ref });
431
+ }
432
+ catch (e) {
433
+ return c.json({ error: String(e.message || e) }, 400);
434
+ }
435
+ });
436
+ // the harness slice of the dashboard input's `/` dropdown — computed by the launcher's HARNESS adapter the same way that harness
437
+ // computes its own `/` menu ([[harness-adapter]]). The client passes `?harness=<id>` for the ACTIVE session,
438
+ // so a codex tab gets CODEX's menu, not the default's; unknown/absent → default. Insert-only on the client.
439
+ app.get('/api/slash-commands', (c) => {
440
+ const h = HARNESSES.find((x) => x.id === c.req.query('harness')) || defaultHarness;
441
+ return c.json(h.slashCommands());
442
+ });
443
+ function uploadFailure(error) {
444
+ if (!(error instanceof UploadError))
445
+ throw error;
446
+ const body = { error: error.message };
447
+ if (error.offset != null)
448
+ body.offset = error.offset;
449
+ return new Response(JSON.stringify(body), { status: error.status, headers: { 'content-type': 'application/json' } });
450
+ }
451
+ // One offset protocol for every attachment. Chunks stream through the existing /api proxy and stage only on
452
+ // the worker machine; completion is the one boundary that makes a prompt-visible absolute path exist.
453
+ app.post('/api/uploads', async (c) => {
454
+ const body = await c.req.json().catch(() => null);
455
+ try {
456
+ return c.json(createUpload(body?.name, body?.size), 201);
457
+ }
458
+ catch (error) {
459
+ return uploadFailure(error);
460
+ }
461
+ });
462
+ app.get('/api/uploads/:id', (c) => {
463
+ try {
464
+ return c.json(uploadStatus(c.req.param('id')));
465
+ }
466
+ catch (error) {
467
+ return uploadFailure(error);
468
+ }
469
+ });
470
+ app.patch('/api/uploads/:id', async (c) => {
471
+ try {
472
+ return c.json(await appendUpload(c.req.param('id'), Number(c.req.header('upload-offset')), c.req.raw.body, c.req.header('content-length')));
473
+ }
474
+ catch (error) {
475
+ return uploadFailure(error);
476
+ }
477
+ });
478
+ app.post('/api/uploads/:id/complete', (c) => {
479
+ try {
480
+ return c.json({ path: completeUpload(c.req.param('id')) }, 201);
481
+ }
482
+ catch (error) {
483
+ return uploadFailure(error);
484
+ }
485
+ });
486
+ app.delete('/api/uploads/:id', (c) => {
487
+ try {
488
+ cancelUpload(c.req.param('id'));
489
+ return c.body(null, 204);
490
+ }
491
+ catch (error) {
492
+ return uploadFailure(error);
493
+ }
494
+ });
495
+ // sessions: real tmux-backed Claude Code sessions. List + spawn, stream the live pane (WebSocket),
496
+ // forward keystrokes, and close.
497
+ app.get('/api/sessions', async (c) => c.json(await listSessions(c.req.query('all') === '1' || c.req.query('all') === 'true')));
498
+ app.get('/api/resources', async (c) => c.json(await collectResourceReport()));
499
+ app.post('/api/sessions', async (c) => {
500
+ const requestKey = c.req.header('idempotency-key') || randomUUID();
501
+ const controller = new AbortController();
502
+ const rawSignal = c.req.raw.signal;
503
+ const outgoing = c.env.outgoing;
504
+ const cancel = () => {
505
+ if (!outgoing?.writableEnded)
506
+ controller.abort(new Error('session-create caller disconnected'));
507
+ };
508
+ const cancelFromRequest = () => controller.abort(rawSignal.reason);
509
+ rawSignal.addEventListener('abort', cancelFromRequest, { once: true });
510
+ outgoing?.once('close', cancel);
511
+ try {
512
+ const body = await c.req.json().catch(() => null);
513
+ const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal });
514
+ // The durable row is now public. Nudge the cheap session projection explicitly so a dashboard does not
515
+ // wait for the best-effort store watcher; any held candidate worktree event remains a separate full claim.
516
+ if (result.status === 201)
517
+ notifyBoardChanged('sessions');
518
+ // A candidate registry event is intentionally held while Git creates the private worktree. Once the
519
+ // transaction has published or cleaned up its record, release the one deferred full refresh.
520
+ flushDeferredWorktreeRegistryChange();
521
+ if (result.status === 201) {
522
+ c.header('Idempotency-Key', requestKey);
523
+ return c.json(result.session, 201);
524
+ }
525
+ return c.json({ error: result.error, ...(result.code ? { code: result.code } : {}), ...(result.phase ? { phase: result.phase } : {}) }, result.status);
526
+ }
527
+ finally {
528
+ flushDeferredWorktreeRegistryChange();
529
+ rawSignal.removeEventListener('abort', cancelFromRequest);
530
+ outgoing?.off('close', cancel);
531
+ }
532
+ });
533
+ // one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
534
+ // dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
535
+ app.get('/api/sessions/:id/review', async (c) => {
536
+ const r = await cockpitReview(c.req.param('id'));
537
+ return r ? c.json(r) : c.json({ error: 'no such session' }, 404);
538
+ });
539
+ // The self-contained HTML is the sole full-model transport exception. Interactive rows, including the CLI,
540
+ // use /api/evals pages; a bare request fails loudly rather than reopening a hidden full JSON path.
541
+ app.get('/api/sessions/:id/evals', async (c) => {
542
+ if (c.req.query('format') === 'html') {
543
+ const m = await buildExportModel(c.req.param('id'));
544
+ return m ? c.html(renderExportHtml(m)) : c.text('no such session', 404);
545
+ }
546
+ return c.json({ error: 'interactive eval rows use /api/evals pagination; use ?format=html only for export' }, 400);
547
+ });
548
+ // the session's live pane as text (one-shot snapshot) for a backend client (`spex session show --capture`). Empty and fail
549
+ // stay distinct: an empty pane is 200 with empty body; unknown id → 404, offline (no live pane) → 409, error → 502.
550
+ app.get('/api/sessions/:id/capture', async (c) => {
551
+ const r = await captureSessionResult(c.req.param('id'));
552
+ if (r.ok)
553
+ return c.text(r.pane);
554
+ if (r.reason === 'unknown')
555
+ return c.text('no such session', 404);
556
+ if (r.reason === 'offline')
557
+ return c.text('session offline (no live pane)', 409);
558
+ return c.text('capture failed', 502);
559
+ });
560
+ // A live adapter-owned execution observation, intentionally distinct from the durable conversation timeline.
561
+ // The response carries only the backend-normalized latest working note and typed tool rows; no transcript bytes
562
+ // or parser schema cross this API boundary.
563
+ app.get('/api/sessions/:id/execution', (c) => {
564
+ const execution = readSessionExecution(c.req.param('id') || '');
565
+ return execution ? c.json(execution) : c.json({ error: 'no such session' }, 404);
566
+ });
567
+ app.get('/api/sessions/:id/execution/stream', (c) => sessionExecutionStream(c));
568
+ // the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
569
+ // FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
570
+ // the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
571
+ app.get('/api/sessions/:id/timeline', (c) => {
572
+ const limit = Number(c.req.query('limit'));
573
+ const r = readTimeline(c.req.param('id'), Number.isFinite(limit) && limit > 0 ? limit : undefined);
574
+ return r ? c.json(r) : c.json({ error: 'no such session' }, 404);
575
+ });
576
+ // the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
577
+ // plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
578
+ // the CLI's show; 404 for an unknown id.
579
+ app.get('/api/sessions/:id/closure', (c) => {
580
+ // A missing close fact and a backend that predates this route are different answers. The client requires
581
+ // this capability marker even on 404, so an old backend can never turn a closed id into "never existed".
582
+ c.header('x-spexcode-close-history', 'v1');
583
+ const closure = findSessionClosure(c.req.param('id'));
584
+ return closure ? c.json(closure) : c.json({ error: 'no terminal close history for this session' }, 404);
585
+ });
586
+ app.get('/api/sessions/:id', async (c) => {
587
+ const id = c.req.param('id');
588
+ const row = (await listSessions(true)).find((s) => s.id === id);
589
+ if (!row)
590
+ return c.json({ error: 'no such session' }, 404);
591
+ return c.json({ ...row, prompt: await sessionPrompt(id) });
592
+ });
593
+ app.get('/api/sessions/:id/files', (c) => {
594
+ try {
595
+ return c.json({ files: listSessionFiles(c.req.param('id')) });
596
+ }
597
+ catch (error) {
598
+ if (error instanceof SessionFileError)
599
+ return c.json({ error: error.message }, error.status);
600
+ throw error;
601
+ }
602
+ });
603
+ const sessionFileDownload = (c) => {
604
+ try {
605
+ const requested = c.req.query('path');
606
+ if (!requested)
607
+ return c.json({ error: 'download needs a posted path' }, 400);
608
+ const file = openSessionFile(c.req.param('id'), requested);
609
+ const preview = c.req.query('preview') === '1';
610
+ const previewType = preview ? sessionFilePreviewKind(file.path) : null;
611
+ if (preview && !previewType)
612
+ return c.json({ error: 'no preview for this file type; download it instead' }, 415);
613
+ if (preview && file.size > SESSION_FILE_PREVIEW_MAX_BYTES)
614
+ return c.json({ error: `preview is limited to ${SESSION_FILE_PREVIEW_MAX_BYTES / (1024 * 1024)} MiB; download this ${file.size}-byte file instead` }, 413);
615
+ const headers = {
616
+ 'Cache-Control': 'no-store',
617
+ 'Content-Disposition': `${preview ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
618
+ 'Content-Length': String(file.size),
619
+ 'Content-Type': previewType?.contentType ?? 'application/octet-stream',
620
+ };
621
+ if (previewType)
622
+ headers['X-Spexcode-Preview-Kind'] = previewType.kind;
623
+ if (c.req.method === 'HEAD')
624
+ return c.body(null, 200, headers);
625
+ return c.body(Readable.toWeb(createReadStream(file.path)), 200, headers);
626
+ }
627
+ catch (error) {
628
+ if (error instanceof SessionFileError)
629
+ return c.json({ error: error.message }, error.status);
630
+ throw error;
631
+ }
632
+ };
633
+ app.get('/api/sessions/:id/files/download', sessionFileDownload);
634
+ app.on('HEAD', '/api/sessions/:id/files/download', sessionFileDownload);
635
+ // lifecycle transitions (thin callers of the session state machine)
636
+ // relaunch ONLY if confirmed offline; demotes working→idle, keeps any declaration. The RESUME GUARD refuses
637
+ // (409) when the agent is alive or its liveness is unproven — restore-on-alive was the incident's kill-shot.
638
+ // `force` (query ?force=1 or JSON {force:true}) overrides for a wedged-but-alive process.
639
+ app.post('/api/sessions/:id/resume', async (c) => {
640
+ const body = await c.req.json().catch(() => ({}));
641
+ const force = body?.force === true || c.req.query('force') === '1';
642
+ const r = await resumeSession(c.req.param('id'), { force });
643
+ return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404));
644
+ });
645
+ // A merge intent to the session's own agent (it runs the merge), never a server merge.
646
+ app.post('/api/sessions/:id/merge', async (c) => {
647
+ const r = await mergeSession(c.req.param('id'));
648
+ return c.json(r, r.dispatched ? 200 : (r.status ?? 409));
649
+ });
650
+ // one WS owns one native tmux client (pty-bridge): server→client = that client's rendered PTY bytes (binary);
651
+ // client→server text controls resize, visibility, and xterm-native input (which carries the mouse/wheel
652
+ // SGR reports xterm produces natively in mouse-report mode). Server→client text commits a completed resize immediately before its binary tmux transaction;
653
+ // hiding starts that viewer's bounded helper release without closing the warm socket. tmux itself resolves wheel input between
654
+ // copy-mode and a mouse-owning TUI. The bridge never splices capture-pane state into this stream.
655
+ // keep-alive ping cadence for the terminal socket — the server half of [[reconnect]]'s heartbeat contract,
656
+ // and the contract's ONE primitive number: the client mirrors it (SERVER_PING_MS in the dashboard's
657
+ // resilientSocket.js, pinned by its test) and DERIVES its silence deadline (2.5×) from it.
658
+ // A healthy link is guaranteed inbound traffic every PING window, so the client may presume an OPEN socket
659
+ // silent past its derived window dead. The same tick also sends a WebSocket protocol ping; browsers answer its
660
+ // pong below JavaScript, so a backend reload stays compatible with a tab running the previous frontend bundle.
661
+ // The server owns that mirror deadline and detaches the viewer itself when a half-open link never reports close.
662
+ // Terminal pixels remain binary frames, so heartbeat controls never enter xterm.
663
+ const TERM_PING_MS = 10000;
664
+ const TERM_DEAD_MS = 2.5 * TERM_PING_MS;
665
+ app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
666
+ const id = c.req.param('id');
667
+ let viewer = null;
668
+ let ping;
669
+ let pongDeadline;
670
+ let cleaned = false;
671
+ const disarmPongDeadline = () => { if (pongDeadline)
672
+ clearTimeout(pongDeadline); pongDeadline = undefined; };
673
+ let armPongDeadline = () => { };
674
+ const cleanup = () => {
675
+ if (cleaned)
676
+ return;
677
+ cleaned = true;
678
+ if (ping)
679
+ clearInterval(ping);
680
+ disarmPongDeadline();
681
+ if (viewer)
682
+ detachViewer(id, viewer);
683
+ viewer = null;
684
+ };
685
+ return {
686
+ onOpen(_evt, ws) {
687
+ viewer = {
688
+ send: (buf) => { try {
689
+ ws.send(Uint8Array.from(buf));
690
+ }
691
+ catch { /* viewer gone */ } },
692
+ commitSize: (cols, rows) => { try {
693
+ ws.send(JSON.stringify({ t: 'resize-commit', cols, rows }));
694
+ }
695
+ catch { /* viewer gone */ } },
696
+ };
697
+ attachViewer(id, viewer);
698
+ armPongDeadline = () => {
699
+ if (cleaned)
700
+ return;
701
+ disarmPongDeadline();
702
+ pongDeadline = setTimeout(() => {
703
+ cleanup();
704
+ try {
705
+ ws.close();
706
+ }
707
+ catch { /* cleanup already detached the dead viewer */ }
708
+ }, TERM_DEAD_MS);
709
+ pongDeadline.unref();
710
+ };
711
+ // `raw` is @hono/node-ws's real ws.WebSocket. Protocol pong is intentionally the server-side liveness
712
+ // signal: unlike an application text reply, every browser generation answers it automatically.
713
+ ws.raw.on('pong', armPongDeadline);
714
+ armPongDeadline();
715
+ ping = setInterval(() => {
716
+ try {
717
+ ws.raw.ping();
718
+ }
719
+ catch { /* viewer gone; onClose reaps */ }
720
+ try {
721
+ ws.send('ping');
722
+ }
723
+ catch { /* client dead-man still needs observable inbound traffic */ }
724
+ }, TERM_PING_MS);
725
+ },
726
+ onMessage(evt) {
727
+ if (!viewer)
728
+ return;
729
+ const data = evt.data;
730
+ // Binary input is ignored; JSON keeps terminal input distinct from binary pane output while preserving
731
+ // xterm's ordered string exactly. The bridge accepts input only from this viewer's visible claim.
732
+ if (typeof data === 'string') {
733
+ if (data === 'pong') {
734
+ armPongDeadline();
735
+ return;
736
+ }
737
+ try {
738
+ const m = JSON.parse(data);
739
+ if (m?.t === 'resize')
740
+ resizeBridge(id, viewer, Number(m.cols), Number(m.rows));
741
+ else if (m?.t === 'visible' && m.visible === false)
742
+ hideViewer(id, viewer);
743
+ else if (m?.t === 'input' && typeof m.data === 'string')
744
+ forwardInput(id, viewer, m.data);
745
+ }
746
+ catch { /* ignore */ }
747
+ }
748
+ },
749
+ onClose() { cleanup(); },
750
+ };
751
+ }));
752
+ // ONE input route, `kind` the discriminator — the transport split is an implementation fact, not API surface.
753
+ // kind:"text" (`spex session send`, the server-side merge dispatch) appends the prompt to the
754
+ // target timeline, then best-effort pokes its adapter. A dead channel delays context injection but does not
755
+ // change the successful append response; 502 means the record rejected the write.
756
+ // kind:"keys" is the LAST-RESORT raw face (`spex session send --keys`): an ORDERED BATCH of
757
+ // nav-mode key tokens over tmux send-keys, delivered in array order so tap order survives
758
+ // ([[nav-mode-key-ordering]]); unstable by nature — callers try a plain text send first. An unknown kind is a
759
+ // loud 400, never a guessed channel.
760
+ app.post('/api/sessions/:id/input', async (c) => {
761
+ const body = await c.req.json().catch(() => ({}));
762
+ if (body?.kind === 'text') {
763
+ // `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
764
+ // edge ([[session-timeline]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
765
+ // terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
766
+ const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined, {
767
+ ...(body?.replyVia === 'note' ? { replyVia: 'note' } : {}),
768
+ });
769
+ return c.json(r, r.ok ? 200 : 502);
770
+ }
771
+ if (body?.kind === 'command') {
772
+ const id = c.req.param('id');
773
+ const text = typeof body?.text === 'string' ? body.text : '';
774
+ const r = await sendText(id, text);
775
+ if (!r.ok)
776
+ return c.json(r, 502);
777
+ const outcomes = await dispatchNewMentions(text, { sessionId: id });
778
+ return c.json({ ...r, outcomes, mentionSummary: summarizeDispatch(outcomes) });
779
+ }
780
+ if (body?.kind === 'keys') {
781
+ const keys = Array.isArray(body?.keys) ? body.keys.filter((k) => typeof k === 'string') : [];
782
+ const ok = await rawKey(c.req.param('id'), keys);
783
+ return c.json({ ok }, ok ? 200 : 404);
784
+ }
785
+ return c.json({ error: 'input needs kind: "text" | "command" | "keys"' }, 400);
786
+ });
787
+ app.post('/api/sessions/reparent', async (c) => {
788
+ const result = await reparentRequest(await c.req.json().catch(() => null));
789
+ notifyBoardChanged('sessions');
790
+ return c.json(result);
791
+ });
792
+ // soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
793
+ // removes the worktree. {ok:false} = no such session.
794
+ app.post('/api/sessions/:id/stop', async (c) => {
795
+ const sessionId = c.req.param('id');
796
+ const ok = await stopSession(sessionId);
797
+ return c.json(ok ? { ok: true } : { ok: false, error: `no stop transition was committed for session ${sessionId}` }, ok ? 200 : 404);
798
+ });
799
+ app.post('/api/sessions/:id/interrupt', async (c) => {
800
+ const result = await interruptSession(c.req.param('id'));
801
+ return c.json(result, result.ok ? 200 : 502);
802
+ });
803
+ app.post('/api/sessions/:id/close', async (c) => {
804
+ const sessionId = c.req.param('id');
805
+ const body = await c.req.json().catch(() => ({}));
806
+ const ok = await closeSession(sessionId, body?.source);
807
+ // The close route owns its write's visible boundary: filesystem watchers can be unavailable, so cache
808
+ // invalidation must happen before the success response rather than leaving the confirming board to patrol.
809
+ if (ok)
810
+ notifyBoardChanged('sessions');
811
+ return c.json(ok ? { ok: true } : { ok: false, error: `no close transition was committed for session ${sessionId}` }, ok ? 200 : 404);
812
+ });
813
+ app.post('/api/sessions/:id/quarantine', async (c) => {
814
+ const body = await c.req.json().catch(() => null);
815
+ const result = await quarantineCorruptRecord(c.req.param('id'), body);
816
+ return c.json({ ok: true, ...result });
817
+ });
818
+ app.post('/api/sessions/:id/quarantine/restore', async (c) => {
819
+ const result = await restoreQuarantinedRecord(c.req.param('id'));
820
+ return c.json({ ok: true, ...result });
821
+ });
822
+ // archive / legacy unarchive signpost ([[archive]]) — archive proves exact cold/offline ownership before filing;
823
+ // `{on:false}` enters the same resume transition and recreates the preserved conversation. {ok:false}=no such session.
824
+ app.post('/api/sessions/:id/archive', async (c) => {
825
+ const body = await c.req.json().catch(() => ({}));
826
+ return c.json({ ok: await archiveSession(c.req.param('id'), body?.on !== false) });
827
+ });
828
+ // set (or clear, with a blank) a session's display-name override; persists to the session's global record
829
+ // (`session.json`) so it survives a restart. Unknown id → 404. That record sits INSIDE the watched store, but
830
+ // the store watch is best-effort (it can fail to attach), so the route still nudges the stream explicitly
831
+ // ([[graph-stream]]) — the rename shows in ~150ms deterministically, never waiting out a cold tick.
832
+ app.post('/api/sessions/:id/rename', async (c) => {
833
+ const body = await c.req.json().catch(() => ({}));
834
+ const ok = await renameSession(c.req.param('id'), typeof body?.name === 'string' ? body.name : '');
835
+ if (ok)
836
+ notifyBoardChanged('sessions');
837
+ return c.json({ ok }, ok ? 200 : 404);
838
+ });
839
+ // Cross-product identity is opt-in and exact: a ZCode tool/hook reports its own opaque child id against the
840
+ // SpexCode session it belongs to. No route derives an association from labels, timing, worktrees, or branches.
841
+ app.post('/api/sessions/:id/zcode-child-sessions', async (c) => {
842
+ const body = await c.req.json().catch(() => null);
843
+ if (!body || typeof body.childSessionId !== 'string' || !body.childSessionId || body.childSessionId.trim() !== body.childSessionId)
844
+ return c.json({ error: 'body needs a non-empty, whitespace-free childSessionId' }, 400);
845
+ const link = await linkZCodeChildSession(c.req.param('id'), body?.childSessionId);
846
+ if (!link)
847
+ return c.json({ error: 'no such governed session' }, 404);
848
+ // The store watch is best effort. The confirming reader must see its asserted identity immediately.
849
+ notifyBoardChanged('sessions');
850
+ return c.json(link, link.alreadyLinked ? 200 : 201);
851
+ });
852
+ // set/clear a session's sort-key ([[session-reorder]]): a finite number pins the row's slot, null (or
853
+ // non-numeric) restores birth order. Mirrors /rename.
854
+ app.post('/api/sessions/:id/sort', async (c) => {
855
+ const body = await c.req.json().catch(() => ({}));
856
+ const key = typeof body?.key === 'number' && Number.isFinite(body.key) ? body.key : null;
857
+ const ok = await setSessionSort(c.req.param('id'), key);
858
+ return c.json({ ok }, ok ? 200 : 404);
859
+ });
860
+ const port = Number(process.env.PORT || 8787);
861
+ // @@@ server-side connection reaping ([[spec-cli]]) - abandoned connections must die SERVER-SIDE, or they
862
+ // pile up and wedge the backend (135 leaked conns once starved :8787 into looking dead — the cascade that
863
+ // triggered the mass-restore incident, since every client-side timeout-kill leaks one). The ONE mechanism is
864
+ // the socket-level `installConnectionReaper` below (reaper.ts): a per-socket deadline that reaps a
865
+ // slow-loris / idle keep-alive but exempts an ACTIVE WS/SSE stream (board-stream, terminal socket) for as
866
+ // long as it streams. Deliberately NO `serverOptions` timeouts here: they were measured to be not harmless
867
+ // but a second mechanism racing the reaper (issue #65 — a 20s headersTimeout won at default config and
868
+ // silently capped SPEXCODE_REAP_HEADER_MS); the install disables Node's overlapping timeouts so the
869
+ // deadlines have a single owner.
870
+ // @@@ loopback bind ([[public-mode]]) - this child is NEVER the internet face: the supervisor (and in public
871
+ // mode the gateway) fronts it, and dials it only via 127.0.0.1. Binding loopback is what makes "loopback is
872
+ // the trust boundary" true — without a hostname Node binds all interfaces and the child is reachable from
873
+ // the LAN with no password, bypassing the gate entirely (measured: eval auth-boundary).
874
+ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' });
875
+ installConnectionReaper(server);
876
+ injectWebSocket(server);
877
+ superviseBridges(); // restore visible helpers after failure; their viewer subscriptions survive replacement
878
+ superviseQueue(); // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
879
+ superviseTurnFailures(); // reconcile adapter-owned native failure subscriptions across backend replacement
880
+ superviseDelivery(); // hand over messages an earlier pass could not ([[delivery-queue]]): the retry half of dispatch
881
+ console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`);
882
+ let graphWatchersClosed = false;
883
+ const closeGraphWatchers = () => {
884
+ if (graphWatchersClosed)
885
+ return;
886
+ graphWatchersClosed = true;
887
+ closeBoardFileWatchers();
888
+ };
889
+ process.once('exit', closeGraphWatchers);
890
+ // graceful drain (the other half of zero-downtime reload, supervise.ts): on SIGTERM stop accepting new
891
+ // connections, let in-flight requests finish, and sweep now-idle keep-alive sockets so close() fires the
892
+ // instant the last request drains. A hard cap still forces exit if a connection won't close.
893
+ process.on('SIGTERM', () => {
894
+ closeGraphWatchers();
895
+ const srv = server;
896
+ const sweep = setInterval(() => srv.closeIdleConnections?.(), 200);
897
+ srv.close(() => { clearInterval(sweep); process.exit(0); });
898
+ setTimeout(() => process.exit(0), 10000).unref();
899
+ });