@spexcode/spec-cli 0.6.6 → 0.6.8

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 (88) hide show
  1. package/bin/spex.mjs +56 -19
  2. package/dist/cli.js +102 -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/execution-trace.d.ts +1 -0
  11. package/dist/execution-trace.js +2 -2
  12. package/dist/gateway-hub.js +2 -1
  13. package/dist/gateway.js +6 -3
  14. package/dist/graphCache.js +32 -2
  15. package/dist/graphSnapshot.js +57 -2
  16. package/dist/graphStream.d.ts +2 -0
  17. package/dist/graphStream.js +83 -3
  18. package/dist/guide.js +20 -7
  19. package/dist/harness-select.js +16 -3
  20. package/dist/harness.d.ts +15 -3
  21. package/dist/harness.js +331 -50
  22. package/dist/help.js +11 -8
  23. package/dist/hook-prompts.js +8 -0
  24. package/dist/host-resources.js +29 -8
  25. package/dist/host.d.ts +7 -0
  26. package/dist/host.js +93 -0
  27. package/dist/index.js +324 -22
  28. package/dist/init.js +1 -1
  29. package/dist/lint.js +70 -35
  30. package/dist/listen.d.ts +3 -2
  31. package/dist/listen.js +14 -2
  32. package/dist/machine-peer.js +1 -1
  33. package/dist/materialize.d.ts +2 -2
  34. package/dist/materialize.js +176 -35
  35. package/dist/pty-bridge.js +14 -14
  36. package/dist/reviews.js +12 -7
  37. package/dist/runtime-ownership.d.ts +11 -0
  38. package/dist/runtime-ownership.js +79 -1
  39. package/dist/session-application.d.ts +23 -0
  40. package/dist/session-application.js +189 -0
  41. package/dist/session-declarations.js +13 -1
  42. package/dist/session-files.d.ts +6 -0
  43. package/dist/session-files.js +13 -1
  44. package/dist/session-follow.js +39 -22
  45. package/dist/session-record-lock.d.ts +3 -0
  46. package/dist/session-record-lock.js +94 -0
  47. package/dist/session-runtime-adapter.d.ts +44 -0
  48. package/dist/session-runtime-adapter.js +37 -0
  49. package/dist/session-timeline.d.ts +25 -2
  50. package/dist/session-timeline.js +68 -11
  51. package/dist/session-web.js +4 -4
  52. package/dist/sessions.d.ts +108 -15
  53. package/dist/sessions.js +1465 -744
  54. package/dist/source-list.d.ts +13 -0
  55. package/dist/source-list.js +99 -0
  56. package/dist/source-read.d.ts +16 -0
  57. package/dist/source-read.js +84 -0
  58. package/dist/spec-attachments.d.ts +7 -0
  59. package/dist/spec-attachments.js +89 -0
  60. package/dist/spec-body-edit.d.ts +23 -0
  61. package/dist/spec-body-edit.js +138 -0
  62. package/dist/supervise.js +15 -6
  63. package/dist/transcript-reader.d.ts +36 -0
  64. package/dist/transcript-reader.js +251 -0
  65. package/hooks/dispatch.sh +19 -31
  66. package/hooks/harness.sh +6 -6
  67. package/package.json +6 -6
  68. package/templates/hooks/post-checkout +4 -2
  69. package/templates/hooks/post-merge +2 -1
  70. package/templates/hooks/pre-commit +5 -3
  71. package/templates/hooks/reference-transaction +5 -3
  72. package/templates/spec/project/.plugins/commands/spec.md +2 -7
  73. package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  74. package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  75. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  76. package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  77. package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  78. package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  79. package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  80. package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  81. package/templates/spec/project/.plugins/core/spec.md +2 -0
  82. package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  83. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  84. package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  85. package/templates/spec/project/.plugins/skills/spec.md +2 -6
  86. package/templates/spec/project/.plugins/spec.md +7 -0
  87. package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  88. 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, readAliasedRawRecord, 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,12 @@ 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
24
  import { readSessionExecution, sessionExecutionStream } from './session-execution.js';
25
- import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js';
25
+ import { defaultHarness, HARNESSES, codexHarness, dashboardLauncherList, launcherDefault, harnessById } from './harness.js';
26
+ import { ensureCodexGenerationLedger, reclaimDrainingCodexGenerations } from './codex-runtime-generations.js';
27
+ import { TranscriptReadError } from './transcript-reader.js';
26
28
  import { readBlobByHash } from '@spexcode/spec-eval/evaltab';
27
29
  import { putBlob } from '@spexcode/spec-eval/cache';
28
30
  import { fileHumanReading } from '@spexcode/spec-eval/filing';
@@ -30,6 +32,10 @@ import { fileHumanOk } from '@spexcode/spec-eval/humanok';
30
32
  import { buildExportModel, renderExportHtml, buildSessionEvals, SessionEvalUnavailableError } from '@spexcode/spec-eval/sessioneval';
31
33
  import { appendUpload, cancelUpload, completeUpload, createUpload, evidenceMaxBytes, startUploadReaper, UploadError, uploadStatus } from './uploads.js';
32
34
  import { listSessionFiles, openSessionFile, SESSION_FILE_PREVIEW_MAX_BYTES, sessionFilePreviewKind, SessionFileError } from './session-files.js';
35
+ import { readSourceSlice, SourceReadError, SOURCE_SLICE_MAX_BYTES } from './source-read.js';
36
+ import { listSourceDir } from './source-list.js';
37
+ import { loadConfig as loadLintConfig } from './lint.js';
38
+ import { listNodeAttachments, readNodeAttachment } from './spec-attachments.js';
33
39
  import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges } from './pty-bridge.js';
34
40
  import { installProcessGuards } from '@spexcode/spec-core';
35
41
  import { resolveProjectIdentity } from '@spexcode/spec-core';
@@ -38,11 +44,17 @@ import { collectResourceReport, ResourceConflict } from './host-resources.js';
38
44
  import { reparentRequest, SessionReparentRequestError } from './session-reparent.js';
39
45
  import { buildGuidanceCatalog } from './guidance-catalog.js';
40
46
  import { installEvalHost } from './eval-host.js';
47
+ import { configuredSessionApplicationIfCutover, setSessionApplicationCommitObserver } from './session-application.js';
48
+ import { editSpecBody, readSpecBodyEdit, SpecBodyEditError } from './spec-body-edit.js';
41
49
  installEvalHost();
42
50
  // last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
43
51
  // self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
44
52
  installProcessGuards();
53
+ startWorktreeTrashReaper();
45
54
  const app = new Hono();
55
+ // Canonical lifecycle commits do not touch a watched JSON file. Bridge those commits into the existing board
56
+ // stream so status/proposal/parent changes arrive without waiting for a later human send or delivery tick.
57
+ setSessionApplicationCommitObserver(() => notifyBoardChanged('sessions'));
46
58
  startUploadReaper();
47
59
  app.use('/api/*', cors());
48
60
  app.onError((error, c) => {
@@ -127,6 +139,24 @@ app.get('/api/specs/:id/content', (c) => {
127
139
  const x = specContent(c.req.param('id'));
128
140
  return x ? c.json(x) : c.json({ body: '', parts: null }, 404);
129
141
  });
142
+ // [[spec-body-edit]]: the WRITE half of the spec document — a human at the board replaces a line range of
143
+ // a node's body and it lands as a real commit. The endpoint takes no path (it derives one from the node id)
144
+ // and rewrites nothing but the body, so a request cannot reach code or frontmatter; a region that moved
145
+ // since it was read is a 409 with the current text, never a merge. The version bump and drift are
146
+ // recomputed from the commit by [[source-of-truth]] — nothing else is written.
147
+ app.post('/api/specs/:id/body', async (c) => {
148
+ try {
149
+ const result = await editSpecBody(c.req.param('id'), readSpecBodyEdit(await c.req.json().catch(() => null)));
150
+ if (result.changed)
151
+ notifyBoardChanged('full');
152
+ return c.json(result);
153
+ }
154
+ catch (e) {
155
+ if (e instanceof SpecBodyEditError)
156
+ return c.json({ error: e.message, code: e.code, ...(e.detail ?? {}) }, e.status);
157
+ throw e;
158
+ }
159
+ });
130
160
  app.get('/api/specs/:id/history', async (c) => c.json(await specHistory(c.req.param('id'))));
131
161
  // the spec.md line diff one version introduced — the history tab's per-version proof-of-change, fetched
132
162
  // lazily when an older version's item expands (the latest version's diff ships with the board as node.lastDiff).
@@ -135,6 +165,64 @@ app.get('/api/specs/:id/diff/:hash', async (c) => c.json(await specDiffAt(c.req.
135
165
  // worktree's working tree. An untracked brand-new node is invisible to `git diff <base>`, so when the base
136
166
  // diff is empty AND status is `??` synthesize an all-additions view via `diff --no-index` (gitTry — --no-index
137
167
  // exits 1, which gitA would swallow). Gated on `??` so a tracked file with no pending change stays empty.
168
+ // [[source-read]]: a governed source file, read as a byte WINDOW. The spec tree names the files it governs
169
+ // but the board could never open one — this is the read half of "spec and code on one screen". The policy
170
+ // gate is `isSourceFile`, the SAME predicate the coverage walk uses, so the set of files the board can show
171
+ // is by construction the set the project governs. Query: path (repo-relative), offset, limit; the response
172
+ // carries the file's total size so the client can page without a second HEAD.
173
+ app.get('/api/source', (c) => {
174
+ try {
175
+ const root = repoRoot();
176
+ 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));
177
+ return c.json(slice);
178
+ }
179
+ catch (e) {
180
+ if (e instanceof SourceReadError)
181
+ return c.json({ error: e.message }, e.status);
182
+ throw e;
183
+ }
184
+ });
185
+ // [[source-list]]: the LISTING half of the same surface. /api/source opens a file the caller can already
186
+ // name; this names what is there to open, one directory at a time, so the explorer can browse ordinary code
187
+ // the way any editor does. Same `isSourceFile` gate and the same refusals — a row the reader clicks and gets
188
+ // a 404 from is worse than a row that was never drawn. `dir` empty lists the governed roots themselves.
189
+ app.get('/api/files', (c) => {
190
+ try {
191
+ const root = repoRoot();
192
+ const cfg = loadLintConfig(root);
193
+ return c.json(listSourceDir(root, c.req.query('dir') || '', cfg, cfg.governedRoots));
194
+ }
195
+ catch (e) {
196
+ if (e instanceof SourceReadError)
197
+ return c.json({ error: e.message }, e.status);
198
+ throw e;
199
+ }
200
+ });
201
+ // [[node-attachments]]: what a node carries in its own folder besides its body and its readings. The board
202
+ // showed exactly one file per node folder; these are the rest — eval contracts, evidence dirs, raw captures.
203
+ // A different gate from /api/source on purpose (the spec tree is the product's own data, deliberately outside
204
+ // the coverage policy), the same windowed read underneath.
205
+ app.get('/api/specs/:id/files', (c) => {
206
+ try {
207
+ return c.json({ files: listNodeAttachments(repoRoot(), c.req.param('id')) });
208
+ }
209
+ catch (e) {
210
+ if (e instanceof SourceReadError)
211
+ return c.json({ error: e.message }, e.status);
212
+ throw e;
213
+ }
214
+ });
215
+ app.get('/api/specs/:id/files/content', (c) => {
216
+ try {
217
+ 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));
218
+ return c.json(slice);
219
+ }
220
+ catch (e) {
221
+ if (e instanceof SourceReadError)
222
+ return c.json({ error: e.message }, e.status);
223
+ throw e;
224
+ }
225
+ });
138
226
  app.get('/api/edit', async (c) => {
139
227
  const source = c.req.query('source') || '', path = c.req.query('path') || '';
140
228
  if (!source || !path)
@@ -265,7 +353,8 @@ app.get('/api/evals/impact', etag(), async (c) => {
265
353
  // most five lightweight neighbors. A missing worktree scope resolves explicitly to trunk; it never
266
354
  // serializes another scenario's history or the scoped model.
267
355
  app.get('/api/evals/detail', etag(), async (c) => {
268
- await ensureBoardFileWatchers(c.req.query('scope')?.trim() || undefined);
356
+ // Detail is a bounded, direct eval read. It builds only the addressed scope and must not
357
+ // synchronously reconcile the global worktree watcher registry before answering.
269
358
  const node = c.req.query('node')?.trim();
270
359
  const scenario = c.req.query('scenario')?.trim();
271
360
  if (!node || !scenario)
@@ -495,6 +584,7 @@ app.delete('/api/uploads/:id', (c) => {
495
584
  // sessions: real tmux-backed Claude Code sessions. List + spawn, stream the live pane (WebSocket),
496
585
  // forward keystrokes, and close.
497
586
  app.get('/api/sessions', async (c) => c.json(await listSessions(c.req.query('all') === '1' || c.req.query('all') === 'true')));
587
+ app.get('/api/sessions/archive-index', async (c) => c.json(await listArchivedSessionIndex()));
498
588
  app.get('/api/resources', async (c) => c.json(await collectResourceReport()));
499
589
  app.post('/api/sessions', async (c) => {
500
590
  const requestKey = c.req.header('idempotency-key') || randomUUID();
@@ -510,7 +600,7 @@ app.post('/api/sessions', async (c) => {
510
600
  outgoing?.once('close', cancel);
511
601
  try {
512
602
  const body = await c.req.json().catch(() => null);
513
- const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal });
603
+ const result = await sessionCreateRequest(body, { requestKey, signal: controller.signal, onPublished: projectCreatedSession });
514
604
  // The durable row is now public. Nudge the cheap session projection explicitly so a dashboard does not
515
605
  // wait for the best-effort store watcher; any held candidate worktree event remains a separate full claim.
516
606
  if (result.status === 201)
@@ -530,12 +620,149 @@ app.post('/api/sessions', async (c) => {
530
620
  outgoing?.off('close', cancel);
531
621
  }
532
622
  });
623
+ const runtimeApplicationOr503 = (_c) => {
624
+ const application = configuredSessionApplicationIfCutover();
625
+ if (!application)
626
+ throw new ResourceConflict('session runtime is unavailable until the legacy JSON store is migrated');
627
+ return application;
628
+ };
629
+ app.get('/api/session-runtime/:id/events', (c) => {
630
+ const application = runtimeApplicationOr503(c);
631
+ return c.json(application.events.read(c.req.param('id')));
632
+ });
633
+ app.get('/api/session-runtime/:id/replay', (c) => {
634
+ const application = runtimeApplicationOr503(c);
635
+ return c.json(application.replayState(c.req.param('id')));
636
+ });
637
+ app.post('/api/session-runtime/:id/state', async (c) => {
638
+ const application = runtimeApplicationOr503(c);
639
+ const body = await c.req.json().catch(() => null);
640
+ if (body?.status !== undefined && typeof body.status !== 'string')
641
+ return c.json({ error: 'status must be a string' }, 400);
642
+ if (body?.proposal !== undefined && body.proposal !== null && typeof body.proposal !== 'string')
643
+ return c.json({ error: 'proposal must be a string or null' }, 400);
644
+ if (body?.note !== undefined && body.note !== null && typeof body.note !== 'string')
645
+ return c.json({ error: 'note must be a string or null' }, 400);
646
+ if (body?.parentSessionId !== undefined && body.parentSessionId !== null && typeof body.parentSessionId !== 'string')
647
+ return c.json({ error: 'parentSessionId must be a string or null' }, 400);
648
+ try {
649
+ const sessionId = c.req.param('id');
650
+ const nextStatus = body?.status ?? application.readState(sessionId)?.status;
651
+ return c.json(application.transitionSession(c.req.param('id'), {
652
+ status: body?.status,
653
+ proposal: body?.proposal,
654
+ note: body?.note,
655
+ parentSessionId: body?.parentSessionId,
656
+ reason: typeof body?.reason === 'string' ? body.reason : null,
657
+ recipientSessionIds: nextStatus === undefined ? undefined : canonicalWatchRecipients(application, sessionId, nextStatus),
658
+ }));
659
+ }
660
+ catch (error) {
661
+ return c.json({ error: error instanceof Error ? error.message : String(error), code: error?.code }, 409);
662
+ }
663
+ });
664
+ app.post('/api/session-runtime/:id/watch', async (c) => {
665
+ const application = runtimeApplicationOr503(c);
666
+ const body = await c.req.json().catch(() => null);
667
+ if (typeof body?.watcherSessionId !== 'string' || !body.watcherSessionId.trim())
668
+ return c.json({ error: 'watcherSessionId is required; identity is never inferred' }, 400);
669
+ const edge = application.attachWatcher(body.watcherSessionId, c.req.param('id'), typeof body.channel === 'string' ? body.channel : undefined);
670
+ return c.json(edge, 201);
671
+ });
672
+ app.post('/api/session-runtime/:id/bind', async (c) => {
673
+ const application = runtimeApplicationOr503(c);
674
+ const body = await c.req.json().catch(() => null);
675
+ if (!body || typeof body.namespace !== 'string' || typeof body.runtimeKind !== 'string' || typeof body.nativeSessionId !== 'string' || typeof body.nativeStartToken !== 'string') {
676
+ return c.json({ error: 'namespace, runtimeKind, nativeSessionId, and nativeStartToken are required; identity is never inferred' }, 400);
677
+ }
678
+ try {
679
+ const binding = application.bindRuntime(c.req.param('id'), {
680
+ namespace: body.namespace,
681
+ runtimeKind: body.runtimeKind,
682
+ nativeSessionId: body.nativeSessionId,
683
+ nativeStartToken: body.nativeStartToken,
684
+ metadata: body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : undefined,
685
+ }, typeof body.expectedGeneration === 'number' ? body.expectedGeneration : undefined);
686
+ return c.json(binding, 200);
687
+ }
688
+ catch (error) {
689
+ return c.json({ error: error instanceof Error ? error.message : String(error), code: error?.code }, 409);
690
+ }
691
+ });
692
+ app.post('/api/session-runtime/:id/publish', async (c) => {
693
+ const application = runtimeApplicationOr503(c);
694
+ const body = await c.req.json().catch(() => null);
695
+ if (typeof body?.kind !== 'string' || typeof body.body !== 'string')
696
+ return c.json({ error: 'kind and UTF-8 body are required' }, 400);
697
+ const result = application.notifyRecipients(c.req.param('id'), {
698
+ kind: body.kind,
699
+ body: Buffer.from(body.body, 'utf8'),
700
+ senderSessionId: typeof body.senderSessionId === 'string' ? body.senderSessionId : undefined,
701
+ });
702
+ return c.json(result, 201);
703
+ });
704
+ app.post('/api/session-runtime/:id/dequeue', async (c) => {
705
+ const application = runtimeApplicationOr503(c);
706
+ const body = await c.req.json().catch(() => null);
707
+ if (typeof body?.namespace !== 'string')
708
+ return c.json({ error: 'namespace is required' }, 400);
709
+ try {
710
+ const message = application.dequeueForRuntime(c.req.param('id'), body.namespace, typeof body.expectedGeneration === 'number' ? body.expectedGeneration : undefined);
711
+ return c.json(message, 200);
712
+ }
713
+ catch (error) {
714
+ return c.json({ error: error instanceof Error ? error.message : String(error), code: error?.code }, 409);
715
+ }
716
+ });
533
717
  // one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
534
718
  // dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
535
719
  app.get('/api/sessions/:id/review', async (c) => {
536
720
  const r = await cockpitReview(c.req.param('id'));
537
721
  return r ? c.json(r) : c.json({ error: 'no such session' }, 404);
538
722
  });
723
+ // Per-worktree diff: the branch's commits plus, while the session's worktree is on disk, its uncommitted
724
+ // changes. Metadata is cheap and patches are fetched per file — `path` with `scope` names which one — with an
725
+ // explicit byte window so a large review never materializes the whole tree in one response.
726
+ app.get('/api/sessions/:id/diff', async (c) => {
727
+ const offset = Math.max(0, Number(c.req.query('offset')) || 0);
728
+ const limit = Math.min(240_000, Math.max(1, Number(c.req.query('limit')) || 120_000));
729
+ const scope = c.req.query('scope') === 'working' ? 'working' : 'branch';
730
+ const result = await sessionDiff(c.req.param('id'), c.req.query('path') || undefined, offset, limit, scope);
731
+ return result ? c.json(result) : c.json({ error: 'no such session' }, 404);
732
+ });
733
+ app.post('/api/sessions/:id/diff-comments', async (c) => {
734
+ const body = await c.req.json().catch(() => ({}));
735
+ try {
736
+ const comment = await saveDiffComment(c.req.param('id'), {
737
+ id: typeof body?.id === 'string' ? body.id : undefined,
738
+ filePath: typeof body?.filePath === 'string' ? body.filePath : '',
739
+ lineStart: Number(body?.lineStart), lineEnd: Number(body?.lineEnd),
740
+ body: typeof body?.body === 'string' ? body.body : '',
741
+ diffIdentity: typeof body?.diffIdentity === 'string' ? body.diffIdentity : '',
742
+ });
743
+ return comment ? c.json(comment, 201) : c.json({ error: 'no such session' }, 404);
744
+ }
745
+ catch (error) {
746
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
747
+ }
748
+ });
749
+ // Retract removes one row from the record's review conversation. 404 for a row (or session) that is not
750
+ // there, so a double-retract is honestly "already gone" rather than a silent success.
751
+ app.delete('/api/sessions/:id/diff-comments/:commentId', async (c) => {
752
+ try {
753
+ const removed = await retractDiffComment(c.req.param('id'), c.req.param('commentId'));
754
+ return removed ? c.json(removed) : c.json({ error: 'no such diff comment' }, 404);
755
+ }
756
+ catch (error) {
757
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
758
+ }
759
+ });
760
+ app.post('/api/sessions/:id/diff-comments/send', async (c) => {
761
+ const body = await c.req.json().catch(() => ({}));
762
+ const ids = Array.isArray(body?.ids) ? body.ids.filter((id) => typeof id === 'string') : undefined;
763
+ const result = await sendDiffComments(c.req.param('id'), ids);
764
+ return c.json(result, result.ok ? 200 : 409);
765
+ });
539
766
  // The self-contained HTML is the sole full-model transport exception. Interactive rows, including the CLI,
540
767
  // use /api/evals pages; a bare request fails loudly rather than reopening a hidden full JSON path.
541
768
  app.get('/api/sessions/:id/evals', async (c) => {
@@ -565,6 +792,52 @@ app.get('/api/sessions/:id/execution', (c) => {
565
792
  return execution ? c.json(execution) : c.json({ error: 'no such session' }, 404);
566
793
  });
567
794
  app.get('/api/sessions/:id/execution/stream', (c) => sessionExecutionStream(c));
795
+ // The native transcript is a bounded payload behind the durable timeline index. Bounds are explicit epoch
796
+ // milliseconds so the route never guesses which status interval the caller intended.
797
+ app.get('/api/sessions/:id/transcript', async (c) => {
798
+ const id = c.req.param('id') || '';
799
+ const fromRaw = c.req.query('from');
800
+ const toRaw = c.req.query('to');
801
+ if (fromRaw == null || toRaw == null || fromRaw === '' || toRaw === '')
802
+ return c.json({ error: 'transcript needs both from and to epoch milliseconds' }, 400);
803
+ const from = Number(fromRaw);
804
+ const to = Number(toRaw);
805
+ if (!Number.isFinite(from) || !Number.isFinite(to) || !Number.isInteger(from) || !Number.isInteger(to) || from >= to)
806
+ return c.json({ error: 'transcript interval is invalid: from and to must be integer epoch milliseconds with from < to' }, 400);
807
+ let raw;
808
+ try {
809
+ raw = readAliasedRawRecord(id);
810
+ }
811
+ catch (error) {
812
+ return c.json({ error: `session ${id} record is unreadable: ${error instanceof Error ? error.message : String(error)}` }, 500);
813
+ }
814
+ if (!raw || !raw.governed)
815
+ return c.json({ error: `session ${id} does not exist` }, 404);
816
+ let harness;
817
+ try {
818
+ harness = harnessById(typeof raw.harness === 'string' && raw.harness ? raw.harness : defaultHarness.id);
819
+ }
820
+ catch (error) {
821
+ return c.json({ error: error instanceof Error ? error.message : String(error) }, 500);
822
+ }
823
+ const threadId = harness.exactNativeTargetId({
824
+ session: raw.session_id,
825
+ harnessSessionId: typeof raw.harness_session_id === 'string' ? raw.harness_session_id : null,
826
+ stopped: !!raw.stopped,
827
+ archived: !!raw.archived,
828
+ });
829
+ if (!threadId)
830
+ return c.json({ error: `session ${id} transcript is unavailable: native harness identity is missing` }, 409);
831
+ try {
832
+ return c.json(await harness.readTranscript(threadId, { from, to }));
833
+ }
834
+ catch (error) {
835
+ if (!(error instanceof TranscriptReadError))
836
+ throw error;
837
+ const status = error.reason === 'unsupported' ? 501 : error.reason === 'invalid' ? 422 : 409;
838
+ return c.json({ error: error.message, reason: error.reason }, status);
839
+ }
840
+ });
568
841
  // the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
569
842
  // FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
570
843
  // the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
@@ -576,13 +849,6 @@ app.get('/api/sessions/:id/timeline', (c) => {
576
849
  // the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
577
850
  // plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
578
851
  // 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
852
  app.get('/api/sessions/:id', async (c) => {
587
853
  const id = c.req.param('id');
588
854
  const row = (await listSessions(true)).find((s) => s.id === id);
@@ -645,7 +911,7 @@ app.post('/api/sessions/:id/resume', async (c) => {
645
911
  // A merge intent to the session's own agent (it runs the merge), never a server merge.
646
912
  app.post('/api/sessions/:id/merge', async (c) => {
647
913
  const r = await mergeSession(c.req.param('id'));
648
- return c.json(r, r.dispatched ? 200 : (r.status ?? 409));
914
+ return c.json(r, r.dispatched ? 200 : 409);
649
915
  });
650
916
  // one WS owns one native tmux client (pty-bridge): server→client = that client's rendered PTY bytes (binary);
651
917
  // client→server text controls resize, visibility, and xterm-native input (which carries the mouse/wheel
@@ -764,7 +1030,10 @@ app.post('/api/sessions/:id/input', async (c) => {
764
1030
  // `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
765
1031
  // edge ([[session-timeline]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
766
1032
  // 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, {
1033
+ const text = typeof body?.text === 'string' ? body.text : '';
1034
+ if (!text.trim())
1035
+ return c.json({ ok: false, error: EMPTY_PROMPT_ERROR }, 400);
1036
+ const r = await sendText(c.req.param('id'), text, typeof body?.from === 'string' ? body.from : undefined, {
768
1037
  ...(body?.replyVia === 'note' ? { replyVia: 'note' } : {}),
769
1038
  });
770
1039
  return c.json(r, r.ok ? 200 : 502);
@@ -772,9 +1041,27 @@ app.post('/api/sessions/:id/input', async (c) => {
772
1041
  if (body?.kind === 'command') {
773
1042
  const id = c.req.param('id');
774
1043
  const text = typeof body?.text === 'string' ? body.text : '';
775
- const r = await sendText(id, text);
1044
+ if (!text.trim())
1045
+ return c.json({ ok: false, error: EMPTY_PROMPT_ERROR }, 400);
1046
+ const deliveryKey = typeof body?.deliveryId === 'string' && body.deliveryId.trim() ? body.deliveryId.trim() : undefined;
1047
+ // Command Box acceptance is the durable append. Do not hold the HTTP request on a
1048
+ // slow native handoff: the delivery supervisor already owns the queued retry path.
1049
+ const r = await sendText(id, text, undefined, deliveryKey ? { deliveryKey, deferDrain: true } : { deferDrain: true });
776
1050
  if (!r.ok)
777
1051
  return c.json(r, 502);
1052
+ // Start the first handoff without holding the HTTP response on native readiness. The queue remains the
1053
+ // acceptance boundary; only a successful dequeue is allowed to publish human activity.
1054
+ // A deferred drain is an asynchronous handoff, not proof that a prompt was delivered. Only the
1055
+ // accepted queue state for this request may reopen a waiting lifecycle; an empty/raced drain must
1056
+ // never turn a focus-only or retry-only path into `working`.
1057
+ const handoffDeferred = r.delivery === 'deferred';
1058
+ void drainSession(id).then(() => {
1059
+ if (!handoffDeferred)
1060
+ return;
1061
+ const application = configuredSessionApplicationIfCutover();
1062
+ if (application ? !application.readPendingMessages(id).length : true)
1063
+ markHumanPromptActive(id);
1064
+ }).catch((error) => console.error(`spex: command handoff deferred for ${id}: ${error instanceof Error ? error.message : String(error)}`));
778
1065
  const outcomes = await dispatchNewMentions(text, { sessionId: id });
779
1066
  return c.json({ ...r, outcomes, mentionSummary: summarizeDispatch(outcomes) });
780
1067
  }
@@ -820,14 +1107,8 @@ app.post('/api/sessions/:id/quarantine/restore', async (c) => {
820
1107
  const result = await restoreQuarantinedRecord(c.req.param('id'));
821
1108
  return c.json({ ok: true, ...result });
822
1109
  });
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
1110
  // 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
1111
+ // (`runtime.json`) so it survives a restart. Unknown id → 404. That envelope sits INSIDE the watched store, but
831
1112
  // the store watch is best-effort (it can fail to attach), so the route still nudges the stream explicitly
832
1113
  // ([[graph-stream]]) — the rename shows in ~150ms deterministically, never waiting out a cold tick.
833
1114
  app.post('/api/sessions/:id/rename', async (c) => {
@@ -875,6 +1156,27 @@ const port = Number(process.env.PORT || 8787);
875
1156
  const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' });
876
1157
  installConnectionReaper(server);
877
1158
  injectWebSocket(server);
1159
+ // Reclaim only stale, draining Codex generations whose exact detached identity and native reference census
1160
+ // prove they have no loaded threads. The current generation and any uncertain/shared process remain intact.
1161
+ try {
1162
+ const root = runtimeRoot();
1163
+ await ensureCodexGenerationLedger(root);
1164
+ const descriptors = new Map((codexHarness.sharedRuntimes?.(root) ?? []).map((descriptor) => [descriptor.key, descriptor]));
1165
+ const results = await reclaimDrainingCodexGenerations(root, async (endpoint) => {
1166
+ const key = endpoint.id === 'legacy' ? 'codex-app-server' : `codex-app-server:${endpoint.id}`;
1167
+ const residency = descriptors.get(key)?.residency;
1168
+ if (!residency)
1169
+ return { healthy: false, referenceIds: [], peerCount: 0 };
1170
+ const result = await residency();
1171
+ return { healthy: result.healthy, referenceIds: result.referenceIds, peerCount: 0 };
1172
+ });
1173
+ for (const result of results)
1174
+ if (result.reclaimed)
1175
+ console.log(`[codex] reclaimed stale generation ${result.generationId}`);
1176
+ }
1177
+ catch (error) {
1178
+ console.error(`[codex] stale generation sweep retained resources: ${error.message}`);
1179
+ }
878
1180
  superviseBridges(); // restore visible helpers after failure; their viewer subscriptions survive replacement
879
1181
  superviseQueue(); // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
880
1182
  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;