@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.
- package/bin/spex.mjs +175 -21
- package/dist/cli.js +121 -59
- package/dist/client.d.ts +1 -3
- package/dist/client.js +49 -30
- package/dist/codex-runtime-generations.d.ts +11 -0
- package/dist/codex-runtime-generations.js +46 -9
- package/dist/delivery-lock.d.ts +2 -0
- package/dist/delivery-lock.js +58 -0
- package/dist/doctor.js +53 -11
- package/dist/file-write.js +14 -2
- package/dist/gateway-hub.js +2 -1
- package/dist/gateway.js +6 -3
- package/dist/graphCache.js +32 -2
- package/dist/graphSnapshot.js +57 -2
- package/dist/graphStream.d.ts +2 -0
- package/dist/graphStream.js +83 -3
- package/dist/guide.js +29 -18
- package/dist/harness-select.js +16 -3
- package/dist/harness.d.ts +24 -6
- package/dist/harness.js +510 -72
- package/dist/help.js +11 -8
- package/dist/hook-prompts.js +8 -0
- package/dist/host-resources.js +29 -8
- package/dist/host.d.ts +7 -0
- package/dist/host.js +98 -1
- package/dist/index.js +294 -36
- package/dist/init.js +1 -1
- package/dist/lint.js +70 -35
- package/dist/listen.d.ts +3 -2
- package/dist/listen.js +14 -2
- package/dist/machine-peer.d.ts +2 -1
- package/dist/machine-peer.js +46 -15
- package/dist/materialize.d.ts +2 -2
- package/dist/materialize.js +176 -35
- package/dist/opencode.js +10 -2
- package/dist/pi-harness.js +18 -5
- package/dist/pi-headless.d.ts +2 -0
- package/dist/pi-headless.js +27 -0
- package/dist/pty-bridge.js +14 -14
- package/dist/reviews.js +12 -7
- package/dist/runtime-ownership.d.ts +11 -0
- package/dist/runtime-ownership.js +79 -1
- package/dist/session-application.d.ts +23 -0
- package/dist/session-application.js +189 -0
- package/dist/session-declarations.js +13 -1
- package/dist/session-files.d.ts +6 -0
- package/dist/session-files.js +13 -1
- package/dist/session-follow.js +39 -22
- package/dist/session-record-lock.d.ts +3 -0
- package/dist/session-record-lock.js +94 -0
- package/dist/session-runtime-adapter.d.ts +44 -0
- package/dist/session-runtime-adapter.js +37 -0
- package/dist/session-timeline.d.ts +25 -2
- package/dist/session-timeline.js +68 -11
- package/dist/session-transcript.d.ts +57 -0
- package/dist/session-transcript.js +137 -0
- package/dist/session-web.js +4 -4
- package/dist/sessions.d.ts +112 -15
- package/dist/sessions.js +1553 -764
- package/dist/shim-runtime.js +14 -0
- package/dist/source-list.d.ts +13 -0
- package/dist/source-list.js +99 -0
- package/dist/source-read.d.ts +16 -0
- package/dist/source-read.js +84 -0
- package/dist/spec-attachments.d.ts +7 -0
- package/dist/spec-attachments.js +89 -0
- package/dist/spec-body-edit.d.ts +23 -0
- package/dist/spec-body-edit.js +138 -0
- package/dist/supervise.js +15 -6
- package/hooks/dispatch.sh +19 -31
- package/hooks/harness.sh +6 -6
- package/package.json +8 -6
- package/templates/hooks/post-checkout +4 -2
- package/templates/hooks/post-merge +2 -1
- package/templates/hooks/pre-commit +5 -3
- package/templates/hooks/reference-transaction +5 -3
- package/templates/spec/project/.plugins/commands/spec.md +2 -7
- package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
- package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
- package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
- package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
- package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
- package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
- package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
- package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
- package/templates/spec/project/.plugins/core/spec.md +2 -0
- package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
- package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
- package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
- package/templates/spec/project/.plugins/skills/spec.md +2 -6
- package/templates/spec/project/.plugins/spec.md +7 -0
- package/dist/execution-trace.d.ts +0 -26
- package/dist/execution-trace.js +0 -475
- package/dist/session-execution.d.ts +0 -10
- package/dist/session-execution.js +0 -70
- package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
- 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,
|
|
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 {
|
|
25
|
-
import { defaultHarness, HARNESSES,
|
|
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
|
|
215
|
-
//
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
//
|
|
561
|
-
//
|
|
562
|
-
//
|
|
563
|
-
app.get('/api/sessions/:id/
|
|
564
|
-
|
|
565
|
-
|
|
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 :
|
|
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
|
|
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
|
-
|
|
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
|
-
// (`
|
|
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;
|