@spexcode/spec-cli 0.6.7 → 0.7.0-next.0

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 (97) hide show
  1. package/bin/spex.mjs +175 -21
  2. package/dist/cli.js +121 -59
  3. package/dist/client.d.ts +1 -3
  4. package/dist/client.js +49 -30
  5. package/dist/codex-runtime-generations.d.ts +11 -0
  6. package/dist/codex-runtime-generations.js +46 -9
  7. package/dist/delivery-lock.d.ts +2 -0
  8. package/dist/delivery-lock.js +58 -0
  9. package/dist/doctor.js +53 -11
  10. package/dist/file-write.js +14 -2
  11. package/dist/gateway-hub.js +2 -1
  12. package/dist/gateway.js +6 -3
  13. package/dist/graphCache.js +32 -2
  14. package/dist/graphSnapshot.js +57 -2
  15. package/dist/graphStream.d.ts +2 -0
  16. package/dist/graphStream.js +83 -3
  17. package/dist/guide.js +29 -18
  18. package/dist/harness-select.js +16 -3
  19. package/dist/harness.d.ts +24 -6
  20. package/dist/harness.js +510 -72
  21. package/dist/help.js +11 -8
  22. package/dist/hook-prompts.js +8 -0
  23. package/dist/host-resources.js +29 -8
  24. package/dist/host.d.ts +7 -0
  25. package/dist/host.js +98 -1
  26. package/dist/index.js +294 -36
  27. package/dist/init.js +1 -1
  28. package/dist/lint.js +70 -35
  29. package/dist/listen.d.ts +3 -2
  30. package/dist/listen.js +14 -2
  31. package/dist/machine-peer.d.ts +2 -1
  32. package/dist/machine-peer.js +46 -15
  33. package/dist/materialize.d.ts +2 -2
  34. package/dist/materialize.js +176 -35
  35. package/dist/opencode.js +10 -2
  36. package/dist/pi-harness.js +18 -5
  37. package/dist/pi-headless.d.ts +2 -0
  38. package/dist/pi-headless.js +27 -0
  39. package/dist/pty-bridge.js +14 -14
  40. package/dist/reviews.js +12 -7
  41. package/dist/runtime-ownership.d.ts +11 -0
  42. package/dist/runtime-ownership.js +79 -1
  43. package/dist/session-application.d.ts +23 -0
  44. package/dist/session-application.js +189 -0
  45. package/dist/session-declarations.js +13 -1
  46. package/dist/session-files.d.ts +6 -0
  47. package/dist/session-files.js +13 -1
  48. package/dist/session-follow.js +39 -22
  49. package/dist/session-record-lock.d.ts +3 -0
  50. package/dist/session-record-lock.js +94 -0
  51. package/dist/session-runtime-adapter.d.ts +44 -0
  52. package/dist/session-runtime-adapter.js +37 -0
  53. package/dist/session-timeline.d.ts +25 -2
  54. package/dist/session-timeline.js +68 -11
  55. package/dist/session-transcript.d.ts +57 -0
  56. package/dist/session-transcript.js +137 -0
  57. package/dist/session-web.js +4 -4
  58. package/dist/sessions.d.ts +112 -15
  59. package/dist/sessions.js +1553 -764
  60. package/dist/shim-runtime.js +14 -0
  61. package/dist/source-list.d.ts +13 -0
  62. package/dist/source-list.js +99 -0
  63. package/dist/source-read.d.ts +16 -0
  64. package/dist/source-read.js +84 -0
  65. package/dist/spec-attachments.d.ts +7 -0
  66. package/dist/spec-attachments.js +89 -0
  67. package/dist/spec-body-edit.d.ts +23 -0
  68. package/dist/spec-body-edit.js +138 -0
  69. package/dist/supervise.js +15 -6
  70. package/hooks/dispatch.sh +19 -31
  71. package/hooks/harness.sh +6 -6
  72. package/package.json +8 -6
  73. package/templates/hooks/post-checkout +4 -2
  74. package/templates/hooks/post-merge +2 -1
  75. package/templates/hooks/pre-commit +5 -3
  76. package/templates/hooks/reference-transaction +5 -3
  77. package/templates/spec/project/.plugins/commands/spec.md +2 -7
  78. package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  79. package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  80. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  81. package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  82. package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  83. package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  84. package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  85. package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  86. package/templates/spec/project/.plugins/core/spec.md +2 -0
  87. package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  88. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  89. package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  90. package/templates/spec/project/.plugins/skills/spec.md +2 -6
  91. package/templates/spec/project/.plugins/spec.md +7 -0
  92. package/dist/execution-trace.d.ts +0 -26
  93. package/dist/execution-trace.js +0 -475
  94. package/dist/session-execution.d.ts +0 -10
  95. package/dist/session-execution.js +0 -70
  96. package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  97. package/hooks/compat/mark-active-sed-v0.fixture +0 -46
package/dist/index.js CHANGED
@@ -7,7 +7,7 @@ import { Hono } from 'hono';
7
7
  import { cors } from 'hono/cors';
8
8
  import { etag } from 'hono/etag';
9
9
  import { createNodeWebSocket } from '@hono/node-ws';
10
- import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig } from '@spexcode/spec-core';
10
+ import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig, runtimeRoot } from '@spexcode/spec-core';
11
11
  import { issuesEnabled, resolveRemark, retractRemark } from './localIssues.js';
12
12
  import { closeIssue, createIssue, findIssue, mergedIssues, promote } from './issues.js';
13
13
  import { remarkWithLoopIn, replyIssueWithLoopIn } from './loop-in.js';
@@ -19,10 +19,11 @@ import { getBoardJson } from './graphCache.js';
19
19
  import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged, flushDeferredWorktreeRegistryChange } from './graphStream.js';
20
20
  import { gitA, gitTry, repoRoot } from '@spexcode/spec-core';
21
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';
22
+ import { EMPTY_PROMPT_ERROR, retractDiffComment, listSessions, listArchivedSessionIndex, sendText, drainSession, markHumanPromptActive, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, linkZCodeChildSession, projectCreatedSession, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, startWorktreeTrashReaper, SessionRecordUnusable, TMUX_SOCK, sessionDiff, saveDiffComment, sendDiffComments, canonicalWatchRecipients } from './sessions.js';
23
23
  import { readTimeline } from './session-timeline.js';
24
- import { readSessionExecution, sessionExecutionStream } from './session-execution.js';
25
- import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js';
24
+ import { readSessionTranscript, readSessionTranscriptTool, sessionTranscriptStream } from './session-transcript.js';
25
+ import { defaultHarness, HARNESSES, codexHarness, launcherList, launcherDefault } from './harness.js';
26
+ import { ensureCodexGenerationLedger, reclaimDrainingCodexGenerations } from './codex-runtime-generations.js';
26
27
  import { readBlobByHash } from '@spexcode/spec-eval/evaltab';
27
28
  import { putBlob } from '@spexcode/spec-eval/cache';
28
29
  import { fileHumanReading } from '@spexcode/spec-eval/filing';
@@ -30,6 +31,10 @@ import { fileHumanOk } from '@spexcode/spec-eval/humanok';
30
31
  import { buildExportModel, renderExportHtml, buildSessionEvals, SessionEvalUnavailableError } from '@spexcode/spec-eval/sessioneval';
31
32
  import { appendUpload, cancelUpload, completeUpload, createUpload, evidenceMaxBytes, startUploadReaper, UploadError, uploadStatus } from './uploads.js';
32
33
  import { listSessionFiles, openSessionFile, SESSION_FILE_PREVIEW_MAX_BYTES, sessionFilePreviewKind, SessionFileError } from './session-files.js';
34
+ import { readSourceSlice, SourceReadError, SOURCE_SLICE_MAX_BYTES } from './source-read.js';
35
+ import { listSourceDir } from './source-list.js';
36
+ import { loadConfig as loadLintConfig } from './lint.js';
37
+ import { listNodeAttachments, readNodeAttachment } from './spec-attachments.js';
33
38
  import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges } from './pty-bridge.js';
34
39
  import { installProcessGuards } from '@spexcode/spec-core';
35
40
  import { resolveProjectIdentity } from '@spexcode/spec-core';
@@ -38,11 +43,17 @@ import { collectResourceReport, ResourceConflict } from './host-resources.js';
38
43
  import { reparentRequest, SessionReparentRequestError } from './session-reparent.js';
39
44
  import { buildGuidanceCatalog } from './guidance-catalog.js';
40
45
  import { installEvalHost } from './eval-host.js';
46
+ import { configuredSessionApplicationIfCutover, setSessionApplicationCommitObserver } from './session-application.js';
47
+ import { editSpecBody, readSpecBodyEdit, SpecBodyEditError } from './spec-body-edit.js';
41
48
  installEvalHost();
42
49
  // last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
43
50
  // self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
44
51
  installProcessGuards();
52
+ startWorktreeTrashReaper();
45
53
  const app = new Hono();
54
+ // Canonical lifecycle commits do not touch a watched JSON file. Bridge those commits into the existing board
55
+ // stream so status/proposal/parent changes arrive without waiting for a later human send or delivery tick.
56
+ setSessionApplicationCommitObserver(() => notifyBoardChanged('sessions'));
46
57
  startUploadReaper();
47
58
  app.use('/api/*', cors());
48
59
  app.onError((error, c) => {
@@ -127,6 +138,24 @@ app.get('/api/specs/:id/content', (c) => {
127
138
  const x = specContent(c.req.param('id'));
128
139
  return x ? c.json(x) : c.json({ body: '', parts: null }, 404);
129
140
  });
141
+ // [[spec-body-edit]]: the WRITE half of the spec document — a human at the board replaces a line range of
142
+ // a node's body and it lands as a real commit. The endpoint takes no path (it derives one from the node id)
143
+ // and rewrites nothing but the body, so a request cannot reach code or frontmatter; a region that moved
144
+ // since it was read is a 409 with the current text, never a merge. The version bump and drift are
145
+ // recomputed from the commit by [[source-of-truth]] — nothing else is written.
146
+ app.post('/api/specs/:id/body', async (c) => {
147
+ try {
148
+ const result = await editSpecBody(c.req.param('id'), readSpecBodyEdit(await c.req.json().catch(() => null)));
149
+ if (result.changed)
150
+ notifyBoardChanged('full');
151
+ return c.json(result);
152
+ }
153
+ catch (e) {
154
+ if (e instanceof SpecBodyEditError)
155
+ return c.json({ error: e.message, code: e.code, ...(e.detail ?? {}) }, e.status);
156
+ throw e;
157
+ }
158
+ });
130
159
  app.get('/api/specs/:id/history', async (c) => c.json(await specHistory(c.req.param('id'))));
131
160
  // the spec.md line diff one version introduced — the history tab's per-version proof-of-change, fetched
132
161
  // lazily when an older version's item expands (the latest version's diff ships with the board as node.lastDiff).
@@ -135,6 +164,64 @@ app.get('/api/specs/:id/diff/:hash', async (c) => c.json(await specDiffAt(c.req.
135
164
  // worktree's working tree. An untracked brand-new node is invisible to `git diff <base>`, so when the base
136
165
  // diff is empty AND status is `??` synthesize an all-additions view via `diff --no-index` (gitTry — --no-index
137
166
  // exits 1, which gitA would swallow). Gated on `??` so a tracked file with no pending change stays empty.
167
+ // [[source-read]]: a governed source file, read as a byte WINDOW. The spec tree names the files it governs
168
+ // but the board could never open one — this is the read half of "spec and code on one screen". The policy
169
+ // gate is `isSourceFile`, the SAME predicate the coverage walk uses, so the set of files the board can show
170
+ // is by construction the set the project governs. Query: path (repo-relative), offset, limit; the response
171
+ // carries the file's total size so the client can page without a second HEAD.
172
+ app.get('/api/source', (c) => {
173
+ try {
174
+ const root = repoRoot();
175
+ const slice = readSourceSlice(root, c.req.query('path') || '', loadLintConfig(root), Number(c.req.query('offset') ?? 0), Number(c.req.query('limit') ?? SOURCE_SLICE_MAX_BYTES));
176
+ return c.json(slice);
177
+ }
178
+ catch (e) {
179
+ if (e instanceof SourceReadError)
180
+ return c.json({ error: e.message }, e.status);
181
+ throw e;
182
+ }
183
+ });
184
+ // [[source-list]]: the LISTING half of the same surface. /api/source opens a file the caller can already
185
+ // name; this names what is there to open, one directory at a time, so the explorer can browse ordinary code
186
+ // the way any editor does. Same `isSourceFile` gate and the same refusals — a row the reader clicks and gets
187
+ // a 404 from is worse than a row that was never drawn. `dir` empty lists the governed roots themselves.
188
+ app.get('/api/files', (c) => {
189
+ try {
190
+ const root = repoRoot();
191
+ const cfg = loadLintConfig(root);
192
+ return c.json(listSourceDir(root, c.req.query('dir') || '', cfg, cfg.governedRoots));
193
+ }
194
+ catch (e) {
195
+ if (e instanceof SourceReadError)
196
+ return c.json({ error: e.message }, e.status);
197
+ throw e;
198
+ }
199
+ });
200
+ // [[node-attachments]]: what a node carries in its own folder besides its body and its readings. The board
201
+ // showed exactly one file per node folder; these are the rest — eval contracts, evidence dirs, raw captures.
202
+ // A different gate from /api/source on purpose (the spec tree is the product's own data, deliberately outside
203
+ // the coverage policy), the same windowed read underneath.
204
+ app.get('/api/specs/:id/files', (c) => {
205
+ try {
206
+ return c.json({ files: listNodeAttachments(repoRoot(), c.req.param('id')) });
207
+ }
208
+ catch (e) {
209
+ if (e instanceof SourceReadError)
210
+ return c.json({ error: e.message }, e.status);
211
+ throw e;
212
+ }
213
+ });
214
+ app.get('/api/specs/:id/files/content', (c) => {
215
+ try {
216
+ const slice = readNodeAttachment(repoRoot(), c.req.param('id'), c.req.query('name') || '', Number(c.req.query('offset') ?? 0), Number(c.req.query('limit') ?? SOURCE_SLICE_MAX_BYTES));
217
+ return c.json(slice);
218
+ }
219
+ catch (e) {
220
+ if (e instanceof SourceReadError)
221
+ return c.json({ error: e.message }, e.status);
222
+ throw e;
223
+ }
224
+ });
138
225
  app.get('/api/edit', async (c) => {
139
226
  const source = c.req.query('source') || '', path = c.req.query('path') || '';
140
227
  if (!source || !path)
@@ -211,11 +298,10 @@ app.post('/api/evidence', async (c) => {
211
298
  });
212
299
  // the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
213
300
  // `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
301
+ // `.layout.main`) and the complete launcher profiles ([[launcher-select]]) the New-Session picker offers —
302
+ // `{ name, harness, cmd, headless }`: the cmd is read-only display data for the picker (the dashboard sits
216
303
  // 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,
304
+ // so the picker pre-selects the SAME launcher a bare `spex session new` uses, else its first row.
219
305
  // Missing defaultLauncher is returned as an actionable config error, not hidden by falling through to the
220
306
  // built-in `claude` launcher.
221
307
  // `tmuxSocket` is the `-L <name>` label our private tmux server runs under (a backend fact, env-overridable),
@@ -223,7 +309,7 @@ app.post('/api/evidence', async (c) => {
223
309
  // beside the blessed `spex session attach` command — the frontend never hardcodes the socket.
224
310
  app.get('/api/settings', async (c) => c.json({
225
311
  layout: await resolveLayout(),
226
- launchers: dashboardLauncherList(),
312
+ launchers: launcherList(),
227
313
  tmuxSocket: TMUX_SOCK,
228
314
  ...launcherDefault(),
229
315
  }));
@@ -265,7 +351,8 @@ app.get('/api/evals/impact', etag(), async (c) => {
265
351
  // most five lightweight neighbors. A missing worktree scope resolves explicitly to trunk; it never
266
352
  // serializes another scenario's history or the scoped model.
267
353
  app.get('/api/evals/detail', etag(), async (c) => {
268
- await ensureBoardFileWatchers(c.req.query('scope')?.trim() || undefined);
354
+ // Detail is a bounded, direct eval read. It builds only the addressed scope and must not
355
+ // synchronously reconcile the global worktree watcher registry before answering.
269
356
  const node = c.req.query('node')?.trim();
270
357
  const scenario = c.req.query('scenario')?.trim();
271
358
  if (!node || !scenario)
@@ -495,6 +582,7 @@ app.delete('/api/uploads/:id', (c) => {
495
582
  // sessions: real tmux-backed Claude Code sessions. List + spawn, stream the live pane (WebSocket),
496
583
  // forward keystrokes, and close.
497
584
  app.get('/api/sessions', async (c) => c.json(await listSessions(c.req.query('all') === '1' || c.req.query('all') === 'true')));
585
+ app.get('/api/sessions/archive-index', async (c) => c.json(await listArchivedSessionIndex()));
498
586
  app.get('/api/resources', async (c) => c.json(await collectResourceReport()));
499
587
  app.post('/api/sessions', async (c) => {
500
588
  const requestKey = c.req.header('idempotency-key') || randomUUID();
@@ -510,7 +598,7 @@ app.post('/api/sessions', async (c) => {
510
598
  outgoing?.once('close', cancel);
511
599
  try {
512
600
  const body = await c.req.json().catch(() => null);
513
- const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal });
601
+ const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal, onPublished: projectCreatedSession });
514
602
  // The durable row is now public. Nudge the cheap session projection explicitly so a dashboard does not
515
603
  // wait for the best-effort store watcher; any held candidate worktree event remains a separate full claim.
516
604
  if (result.status === 201)
@@ -530,12 +618,149 @@ app.post('/api/sessions', async (c) => {
530
618
  outgoing?.off('close', cancel);
531
619
  }
532
620
  });
621
+ const runtimeApplicationOr503 = (_c) => {
622
+ const application = configuredSessionApplicationIfCutover();
623
+ if (!application)
624
+ throw new ResourceConflict('session runtime is unavailable until the legacy JSON store is migrated');
625
+ return application;
626
+ };
627
+ app.get('/api/session-runtime/:id/events', (c) => {
628
+ const application = runtimeApplicationOr503(c);
629
+ return c.json(application.events.read(c.req.param('id')));
630
+ });
631
+ app.get('/api/session-runtime/:id/replay', (c) => {
632
+ const application = runtimeApplicationOr503(c);
633
+ return c.json(application.replayState(c.req.param('id')));
634
+ });
635
+ app.post('/api/session-runtime/:id/state', async (c) => {
636
+ const application = runtimeApplicationOr503(c);
637
+ const body = await c.req.json().catch(() => null);
638
+ if (body?.status !== undefined && typeof body.status !== 'string')
639
+ return c.json({ error: 'status must be a string' }, 400);
640
+ if (body?.proposal !== undefined && body.proposal !== null && typeof body.proposal !== 'string')
641
+ return c.json({ error: 'proposal must be a string or null' }, 400);
642
+ if (body?.note !== undefined && body.note !== null && typeof body.note !== 'string')
643
+ return c.json({ error: 'note must be a string or null' }, 400);
644
+ if (body?.parentSessionId !== undefined && body.parentSessionId !== null && typeof body.parentSessionId !== 'string')
645
+ return c.json({ error: 'parentSessionId must be a string or null' }, 400);
646
+ try {
647
+ const sessionId = c.req.param('id');
648
+ const nextStatus = body?.status ?? application.readState(sessionId)?.status;
649
+ return c.json(application.transitionSession(c.req.param('id'), {
650
+ status: body?.status,
651
+ proposal: body?.proposal,
652
+ note: body?.note,
653
+ parentSessionId: body?.parentSessionId,
654
+ reason: typeof body?.reason === 'string' ? body.reason : null,
655
+ recipientSessionIds: nextStatus === undefined ? undefined : canonicalWatchRecipients(application, sessionId, nextStatus),
656
+ }));
657
+ }
658
+ catch (error) {
659
+ return c.json({ error: error instanceof Error ? error.message : String(error), code: error?.code }, 409);
660
+ }
661
+ });
662
+ app.post('/api/session-runtime/:id/watch', async (c) => {
663
+ const application = runtimeApplicationOr503(c);
664
+ const body = await c.req.json().catch(() => null);
665
+ if (typeof body?.watcherSessionId !== 'string' || !body.watcherSessionId.trim())
666
+ return c.json({ error: 'watcherSessionId is required; identity is never inferred' }, 400);
667
+ const edge = application.attachWatcher(body.watcherSessionId, c.req.param('id'), typeof body.channel === 'string' ? body.channel : undefined);
668
+ return c.json(edge, 201);
669
+ });
670
+ app.post('/api/session-runtime/:id/bind', async (c) => {
671
+ const application = runtimeApplicationOr503(c);
672
+ const body = await c.req.json().catch(() => null);
673
+ if (!body || typeof body.namespace !== 'string' || typeof body.runtimeKind !== 'string' || typeof body.nativeSessionId !== 'string' || typeof body.nativeStartToken !== 'string') {
674
+ return c.json({ error: 'namespace, runtimeKind, nativeSessionId, and nativeStartToken are required; identity is never inferred' }, 400);
675
+ }
676
+ try {
677
+ const binding = application.bindRuntime(c.req.param('id'), {
678
+ namespace: body.namespace,
679
+ runtimeKind: body.runtimeKind,
680
+ nativeSessionId: body.nativeSessionId,
681
+ nativeStartToken: body.nativeStartToken,
682
+ metadata: body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : undefined,
683
+ }, typeof body.expectedGeneration === 'number' ? body.expectedGeneration : undefined);
684
+ return c.json(binding, 200);
685
+ }
686
+ catch (error) {
687
+ return c.json({ error: error instanceof Error ? error.message : String(error), code: error?.code }, 409);
688
+ }
689
+ });
690
+ app.post('/api/session-runtime/:id/publish', async (c) => {
691
+ const application = runtimeApplicationOr503(c);
692
+ const body = await c.req.json().catch(() => null);
693
+ if (typeof body?.kind !== 'string' || typeof body.body !== 'string')
694
+ return c.json({ error: 'kind and UTF-8 body are required' }, 400);
695
+ const result = application.notifyRecipients(c.req.param('id'), {
696
+ kind: body.kind,
697
+ body: Buffer.from(body.body, 'utf8'),
698
+ senderSessionId: typeof body.senderSessionId === 'string' ? body.senderSessionId : undefined,
699
+ });
700
+ return c.json(result, 201);
701
+ });
702
+ app.post('/api/session-runtime/:id/dequeue', async (c) => {
703
+ const application = runtimeApplicationOr503(c);
704
+ const body = await c.req.json().catch(() => null);
705
+ if (typeof body?.namespace !== 'string')
706
+ return c.json({ error: 'namespace is required' }, 400);
707
+ try {
708
+ const message = application.dequeueForRuntime(c.req.param('id'), body.namespace, typeof body.expectedGeneration === 'number' ? body.expectedGeneration : undefined);
709
+ return c.json(message, 200);
710
+ }
711
+ catch (error) {
712
+ return c.json({ error: error instanceof Error ? error.message : String(error), code: error?.code }, 409);
713
+ }
714
+ });
533
715
  // one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
534
716
  // dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
535
717
  app.get('/api/sessions/:id/review', async (c) => {
536
718
  const r = await cockpitReview(c.req.param('id'));
537
719
  return r ? c.json(r) : c.json({ error: 'no such session' }, 404);
538
720
  });
721
+ // Per-worktree diff: the branch's commits plus, while the session's worktree is on disk, its uncommitted
722
+ // changes. Metadata is cheap and patches are fetched per file — `path` with `scope` names which one — with an
723
+ // explicit byte window so a large review never materializes the whole tree in one response.
724
+ app.get('/api/sessions/:id/diff', async (c) => {
725
+ const offset = Math.max(0, Number(c.req.query('offset')) || 0);
726
+ const limit = Math.min(240_000, Math.max(1, Number(c.req.query('limit')) || 120_000));
727
+ const scope = c.req.query('scope') === 'working' ? 'working' : 'branch';
728
+ const result = await sessionDiff(c.req.param('id'), c.req.query('path') || undefined, offset, limit, scope);
729
+ return result ? c.json(result) : c.json({ error: 'no such session' }, 404);
730
+ });
731
+ app.post('/api/sessions/:id/diff-comments', async (c) => {
732
+ const body = await c.req.json().catch(() => ({}));
733
+ try {
734
+ const comment = await saveDiffComment(c.req.param('id'), {
735
+ id: typeof body?.id === 'string' ? body.id : undefined,
736
+ filePath: typeof body?.filePath === 'string' ? body.filePath : '',
737
+ lineStart: Number(body?.lineStart), lineEnd: Number(body?.lineEnd),
738
+ body: typeof body?.body === 'string' ? body.body : '',
739
+ diffIdentity: typeof body?.diffIdentity === 'string' ? body.diffIdentity : '',
740
+ });
741
+ return comment ? c.json(comment, 201) : c.json({ error: 'no such session' }, 404);
742
+ }
743
+ catch (error) {
744
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
745
+ }
746
+ });
747
+ // Retract removes one row from the record's review conversation. 404 for a row (or session) that is not
748
+ // there, so a double-retract is honestly "already gone" rather than a silent success.
749
+ app.delete('/api/sessions/:id/diff-comments/:commentId', async (c) => {
750
+ try {
751
+ const removed = await retractDiffComment(c.req.param('id'), c.req.param('commentId'));
752
+ return removed ? c.json(removed) : c.json({ error: 'no such diff comment' }, 404);
753
+ }
754
+ catch (error) {
755
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
756
+ }
757
+ });
758
+ app.post('/api/sessions/:id/diff-comments/send', async (c) => {
759
+ const body = await c.req.json().catch(() => ({}));
760
+ const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === 'string') : undefined;
761
+ const result = await sendDiffComments(c.req.param('id'), ids);
762
+ return c.json(result, result.ok ? 200 : 409);
763
+ });
539
764
  // The self-contained HTML is the sole full-model transport exception. Interactive rows, including the CLI,
540
765
  // use /api/evals pages; a bare request fails loudly rather than reopening a hidden full JSON path.
541
766
  app.get('/api/sessions/:id/evals', async (c) => {
@@ -557,14 +782,12 @@ app.get('/api/sessions/:id/capture', async (c) => {
557
782
  return c.text('session offline (no live pane)', 409);
558
783
  return c.text('capture failed', 502);
559
784
  });
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));
785
+ // The native transcript behind the durable timeline index ([[transcript-reader]], [[session-transcript]]):
786
+ // a closed interval by GET, the open working interval by SSE. Bounds are explicit epoch milliseconds so the
787
+ // route never guesses which stretch the caller intended; native bytes stay behind the adapter.
788
+ app.get('/api/sessions/:id/transcript', (c) => readSessionTranscript(c));
789
+ app.get('/api/sessions/:id/transcript/stream', (c) => sessionTranscriptStream(c));
790
+ app.get('/api/sessions/:id/transcript/tool/:toolId', (c) => readSessionTranscriptTool(c));
568
791
  // the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
569
792
  // FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
570
793
  // the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
@@ -576,13 +799,6 @@ app.get('/api/sessions/:id/timeline', (c) => {
576
799
  // the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
577
800
  // plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
578
801
  // 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
802
  app.get('/api/sessions/:id', async (c) => {
587
803
  const id = c.req.param('id');
588
804
  const row = (await listSessions(true)).find((s) => s.id === id);
@@ -645,7 +861,7 @@ app.post('/api/sessions/:id/resume', async (c) => {
645
861
  // A merge intent to the session's own agent (it runs the merge), never a server merge.
646
862
  app.post('/api/sessions/:id/merge', async (c) => {
647
863
  const r = await mergeSession(c.req.param('id'));
648
- return c.json(r, r.dispatched ? 200 : (r.status ?? 409));
864
+ return c.json(r, r.dispatched ? 200 : 409);
649
865
  });
650
866
  // one WS owns one native tmux client (pty-bridge): server→client = that client's rendered PTY bytes (binary);
651
867
  // client→server text controls resize, visibility, and xterm-native input (which carries the mouse/wheel
@@ -764,7 +980,10 @@ app.post('/api/sessions/:id/input', async (c) => {
764
980
  // `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
765
981
  // edge ([[session-timeline]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
766
982
  // terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
767
- const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined, {
983
+ const text = typeof body?.text === 'string' ? body.text : '';
984
+ if (!text.trim())
985
+ return c.json({ ok: false, error: EMPTY_PROMPT_ERROR }, 400);
986
+ const r = await sendText(c.req.param('id'), text, typeof body?.from === 'string' ? body.from : undefined, {
768
987
  ...(body?.replyVia === 'note' ? { replyVia: 'note' } : {}),
769
988
  });
770
989
  return c.json(r, r.ok ? 200 : 502);
@@ -772,9 +991,33 @@ app.post('/api/sessions/:id/input', async (c) => {
772
991
  if (body?.kind === 'command') {
773
992
  const id = c.req.param('id');
774
993
  const text = typeof body?.text === 'string' ? body.text : '';
775
- const r = await sendText(id, text);
994
+ if (!text.trim())
995
+ return c.json({ ok: false, error: EMPTY_PROMPT_ERROR }, 400);
996
+ const deliveryKey = typeof body?.deliveryId === 'string' && body.deliveryId.trim() ? body.deliveryId.trim() : undefined;
997
+ // Command Box acceptance is the durable append. Do not hold the HTTP request on a
998
+ // slow native handoff: the delivery supervisor already owns the queued retry path. The Conversation
999
+ // footer is a Command Box on a terminal-free surface, so it says `replyVia:"note"` here exactly as a
1000
+ // text send would, and the note-reply insert rides the same delivery.
1001
+ const r = await sendText(id, text, undefined, {
1002
+ deferDrain: true,
1003
+ ...(deliveryKey ? { deliveryKey } : {}),
1004
+ ...(body?.replyVia === 'note' ? { replyVia: 'note' } : {}),
1005
+ });
776
1006
  if (!r.ok)
777
1007
  return c.json(r, 502);
1008
+ // Start the first handoff without holding the HTTP response on native readiness. The queue remains the
1009
+ // acceptance boundary; only a successful dequeue is allowed to publish human activity.
1010
+ // A deferred drain is an asynchronous handoff, not proof that a prompt was delivered. Only the
1011
+ // accepted queue state for this request may reopen a waiting lifecycle; an empty/raced drain must
1012
+ // never turn a focus-only or retry-only path into `working`.
1013
+ const handoffDeferred = r.delivery === 'deferred';
1014
+ void drainSession(id).then(() => {
1015
+ if (!handoffDeferred)
1016
+ return;
1017
+ const application = configuredSessionApplicationIfCutover();
1018
+ if (application ? !application.readPendingMessages(id).length : true)
1019
+ markHumanPromptActive(id);
1020
+ }).catch((error) => console.error(`spex: command handoff deferred for ${id}: ${error instanceof Error ? error.message : String(error)}`));
778
1021
  const outcomes = await dispatchNewMentions(text, { sessionId: id });
779
1022
  return c.json({ ...r, outcomes, mentionSummary: summarizeDispatch(outcomes) });
780
1023
  }
@@ -820,14 +1063,8 @@ app.post('/api/sessions/:id/quarantine/restore', async (c) => {
820
1063
  const result = await restoreQuarantinedRecord(c.req.param('id'));
821
1064
  return c.json({ ok: true, ...result });
822
1065
  });
823
- // archive / legacy unarchive signpost ([[archive]]) — archive proves exact cold/offline ownership before filing;
824
- // `{on:false}` enters the same resume transition and recreates the preserved conversation. {ok:false}=no such session.
825
- app.post('/api/sessions/:id/archive', async (c) => {
826
- const body = await c.req.json().catch(() => ({}));
827
- return c.json({ ok: await archiveSession(c.req.param('id'), body?.on !== false) });
828
- });
829
1066
  // set (or clear, with a blank) a session's display-name override; persists to the session's global record
830
- // (`session.json`) so it survives a restart. Unknown id → 404. That record sits INSIDE the watched store, but
1067
+ // (`runtime.json`) so it survives a restart. Unknown id → 404. That envelope sits INSIDE the watched store, but
831
1068
  // the store watch is best-effort (it can fail to attach), so the route still nudges the stream explicitly
832
1069
  // ([[graph-stream]]) — the rename shows in ~150ms deterministically, never waiting out a cold tick.
833
1070
  app.post('/api/sessions/:id/rename', async (c) => {
@@ -875,6 +1112,27 @@ const port = Number(process.env.PORT || 8787);
875
1112
  const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' });
876
1113
  installConnectionReaper(server);
877
1114
  injectWebSocket(server);
1115
+ // Reclaim only stale, draining Codex generations whose exact detached identity and native reference census
1116
+ // prove they have no loaded threads. The current generation and any uncertain/shared process remain intact.
1117
+ try {
1118
+ const root = runtimeRoot();
1119
+ await ensureCodexGenerationLedger(root);
1120
+ const descriptors = new Map((codexHarness.sharedRuntimes?.(root) ?? []).map((descriptor) => [descriptor.key, descriptor]));
1121
+ const results = await reclaimDrainingCodexGenerations(root, async (endpoint) => {
1122
+ const key = endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`;
1123
+ const residency = descriptors.get(key)?.residency;
1124
+ if (!residency)
1125
+ return { healthy: false, referenceIds: [], peerCount: 0 };
1126
+ const result = await residency();
1127
+ return { healthy: result.healthy, referenceIds: result.referenceIds, peerCount: 0 };
1128
+ });
1129
+ for (const result of results)
1130
+ if (result.reclaimed)
1131
+ console.log(`[codex] reclaimed stale generation ${result.generationId}`);
1132
+ }
1133
+ catch (error) {
1134
+ console.error(`[codex] stale generation sweep retained resources: ${error.message}`);
1135
+ }
878
1136
  superviseBridges(); // restore visible helpers after failure; their viewer subscriptions survive replacement
879
1137
  superviseQueue(); // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
880
1138
  superviseTurnFailures(); // reconcile adapter-owned native failure subscriptions across backend replacement
package/dist/init.js CHANGED
@@ -127,7 +127,7 @@ export async function specInit(targetArg, presetArg, harnessArg) {
127
127
  const flagRaw = (harnessArg ?? '').trim() ? parseHarnessFlag(harnessArg.trim()) : null;
128
128
  const chosenHarnesses = flagRaw ?? readConfig(targetDir).harnesses ?? null;
129
129
  if (chosenHarnesses === null) {
130
- console.error(`spex init: --harness is required — name the harness(es) this repo delivers into, e.g. \`spex init --harness claude\`. Known native ids: ${NATIVE_HARNESS_IDS.join(', ')} (comma-separate several; a plugin bundle: --harness plugin:<folder>). A pre-existing spexcode.json "harnesses" field also satisfies this.`);
130
+ console.error(`spex init: --harness is required — name the harness(es) this repo delivers into, e.g. \`spex init --harness claude\`. Known native ids: ${NATIVE_HARNESS_IDS.join(', ')} (comma-separate several; a plugin bundle: --harness plugin:<folder>). Use --harness none to adopt the spec tree and its lint WITHOUT writing into any agent's config. A pre-existing spexcode.json "harnesses" field also satisfies this.`);
131
131
  process.exit(1);
132
132
  }
133
133
  let selectedNativeEvents = null;