spexcode 0.5.8 → 0.6.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/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +122 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/delivery-queue.ts +107 -0
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +71 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +91 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +235 -1021
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
package/spec-cli/src/help.ts
CHANGED
|
@@ -25,9 +25,9 @@ const DOT_NOTE = `\`.\` as a node argument means the node THIS worktree works on
|
|
|
25
25
|
node/<id> branch). One-shot payload reads (graph · spec search · session ls/show/review · eval ls ·
|
|
26
26
|
scenario ls · issue ls/show/links) take --json.`
|
|
27
27
|
|
|
28
|
-
const MENTION_NOTE = `Mentions: @session · [[node]]
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const MENTION_NOTE = `Mentions: @session · [[node]] work in ANY prompt, issue, or remark body — text passed as a CLI arg included.
|
|
29
|
+
[[node]] names a topic; @session names a retained session for the receiving agent to inspect, contact with
|
|
30
|
+
\`spex session send\`, or inherit with \`/distill\`. Mentions are references only: they never send or spawn.`
|
|
31
31
|
|
|
32
32
|
type SessionVerbNote = 'selector' | 'project-bound'
|
|
33
33
|
type SessionVerbHelp = readonly [usage: string | readonly string[], detail: string, notes?: readonly SessionVerbNote[]]
|
|
@@ -44,20 +44,22 @@ session to that node. --prompt-file <path>|- carries a long prompt without shell
|
|
|
44
44
|
ls: ['spex session ls [SEL…] [--status a,b] [--all] [--json]',
|
|
45
45
|
'One-shot table of living sessions. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it.', ['selector']],
|
|
46
46
|
resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
|
|
47
|
-
watch: ['spex session watch [SEL…] [--as NAME] [--idle] [--interval N=
|
|
47
|
+
watch: ['spex session watch [SEL…] [--as NAME] [--idle] [--interval N=1]',
|
|
48
48
|
'Streams lifecycle transitions and blocks until killed; `session wait` is the one-shot alternative.', ['selector']],
|
|
49
|
-
wait: ['spex session wait
|
|
50
|
-
`EDGE-TRIGGERED wait
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
use \`session ls\` / \`session review
|
|
56
|
-
|
|
49
|
+
wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
|
|
50
|
+
`EDGE-TRIGGERED wait: follows the selected sessions' logs AND your own log, and exits 0 on
|
|
51
|
+
the FIRST thing worth waking for — a followed session TRANSITIONING from a non-actionable
|
|
52
|
+
status into an actionable one (stdout = the observed path, e.g. working→review; read the LAST
|
|
53
|
+
token as the status reached), or a message arriving for you (stdout = message). The arrival
|
|
54
|
+
status is printed immediately on stderr but never returns it: it NEVER returns just because a
|
|
55
|
+
session is actionable ALREADY — for "what is it right NOW" use \`session ls\` / \`session review\`.
|
|
56
|
+
It reads files only, so it needs no \`spex serve\` and costs the sessions it follows nothing.
|
|
57
|
+
--timeout is the guaranteed exit (code 1, observed path on stderr); code 2 = a followed
|
|
58
|
+
session's store is gone.`, ['selector']],
|
|
57
59
|
review: ['spex session review <SEL> [--json]', 'Reports ahead · uncommitted · proposal · gates · merge-base diff.', ['selector']],
|
|
58
60
|
merge: ['spex session merge <SEL>', 'Dispatches a gated merge to the session\'s own agent; it does not close the session.', ['selector', 'project-bound']],
|
|
59
61
|
send: [['spex session send <SEL> "<msg>"', 'spex session send <SEL> --keys "<keys>"'],
|
|
60
|
-
`Plain send delivers a message
|
|
62
|
+
`Plain send delivers a message once its timeline append succeeds; a dead adapter only delays its context. --keys is the LAST RESORT:
|
|
61
63
|
raw nav-mode keystrokes to a TUI dialog ("Up Up Enter", C-/M-/S- combos). The raw key surface
|
|
62
64
|
is UNSTABLE and can confirm dangerous dialogs — try a plain send first; use keys only when text
|
|
63
65
|
provably cannot land.`, ['selector', 'project-bound']],
|
|
@@ -69,18 +71,17 @@ provably cannot land.`, ['selector', 'project-bound']],
|
|
|
69
71
|
stop: ['spex session stop <SEL>', 'Soft stop: kill the exact agent and KEEP the worktree resumable.', ['selector', 'project-bound']],
|
|
70
72
|
archive: ['spex session archive <SEL>', 'Cold-archive it: exact leaf/runtime stopped, worktree and conversation kept.', ['selector']],
|
|
71
73
|
unarchive: ['spex session unarchive <SEL>', 'Deprecated compatibility spelling: same behavior as resume, relaunching the same conversation.', ['selector']],
|
|
72
|
-
close: ['spex session close <SEL>',
|
|
74
|
+
close: ['spex session close <SEL>', `Retire ANOTHER session — one you dispatched — deleting its worktree, branch and record.
|
|
75
|
+
<SEL> names that session; it is never \`.\` and never your own id. Closing yourself deletes the worktree
|
|
76
|
+
you are running in, mid-turn. Your own ending is a declaration: \`done --propose close\`.`, ['selector', 'project-bound']],
|
|
73
77
|
quarantine: ['spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <id> --worktree <absent-path> --branch <absent-branch> [--restore]',
|
|
74
|
-
'Move only an unreadable record after the backend proves every named residue absent. Quarantine and --restore both require the original exact id because corrupt rows are outside selectors.', ['project-bound']],
|
|
75
|
-
|
|
76
|
-
'
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
done: ['spex session done --propose merge|nothing|close [--note T]', 'Declare your own work committed and stop.'],
|
|
82
|
-
park: ['spex session park --note <what-you-await>', 'Declare that a real background task will wake your own session.'],
|
|
83
|
-
ask: ['spex session ask --note <your-question>', 'Declare that your own session is stopped on the human and resumes on reply.'],
|
|
78
|
+
'Move only an unreadable record after the backend proves every named residue absent. --thread is an adapter-native conversation id, never the SpexCode session id; omit it for Claude. Quarantine and --restore both require the original exact id because corrupt rows are outside selectors.', ['project-bound']],
|
|
79
|
+
done: ['spex session done --propose merge|nothing|close [--note T]',
|
|
80
|
+
'`merge` declares review: committed work ready for human review, and it is the ONLY declaration that offers a clickable merge. `nothing` declares done: committed work, but no merge proposal. `close` declares close-pending: PROPOSE discarding this worktree — the human closes it. This declaration is how a session ends itself; never run `session close` on your own id.'],
|
|
81
|
+
park: ['spex session park --note <what-you-await>',
|
|
82
|
+
'Declare parked only when a real background task will wake your own session. It self-resumes; waiting for a human is asking, not parked.'],
|
|
83
|
+
ask: ['spex session ask --note <your-question>',
|
|
84
|
+
'Declare asking when the session needs a human reply or direction. It resumes only when the human replies; a background wake-up is parked instead.'],
|
|
84
85
|
attach: ['spex session attach <SEL>', `Attaches the current terminal to the worker's tmux (detach: C-b d) and blocks until detached.
|
|
85
86
|
LOCAL-only (fails loud on a remote backend); show --capture and send are non-interactive.`, ['selector']],
|
|
86
87
|
}
|
|
@@ -88,12 +89,12 @@ LOCAL-only (fails loud on a remote backend); show --capture and send are non-int
|
|
|
88
89
|
|
|
89
90
|
const SESSION_HELP_GROUPS = [
|
|
90
91
|
{ title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge'] },
|
|
91
|
-
{ title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'quarantine'
|
|
92
|
+
{ title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'quarantine'] },
|
|
92
93
|
{ title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask'] },
|
|
93
94
|
{ title: 'Human escape hatch', verbs: ['attach'] },
|
|
94
95
|
] as const
|
|
95
96
|
|
|
96
|
-
const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/
|
|
97
|
+
const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge) are PROJECT-BOUND: a backend serving
|
|
97
98
|
another project's repo refuses loudly — name the target with --api <url> to drive it on purpose.`
|
|
98
99
|
|
|
99
100
|
function indent(text: string, spaces: number): string {
|
|
@@ -230,7 +230,9 @@ const sharedDescriptors = (recs: RawRecord[], retainRegistry = false): Map<strin
|
|
|
230
230
|
// contribute nothing to the active set while still preserving exact ownership if the invariant is violated.
|
|
231
231
|
if (!rec.governed) continue
|
|
232
232
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
233
|
+
const exactKey = harness.targetDescriptorKey?.({ session: rec.session_id, harnessSessionId: rec.harness_session_id }) ?? null
|
|
233
234
|
for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
|
|
235
|
+
if (exactKey && descriptor.key !== exactKey) continue
|
|
234
236
|
const entry = out.get(descriptor.key) ?? { descriptor, recs: [] }
|
|
235
237
|
entry.recs.push(rec)
|
|
236
238
|
out.set(descriptor.key, entry)
|
|
@@ -386,8 +388,15 @@ const sessionStopBlocker = async (
|
|
|
386
388
|
knownProbes?: Map<string, SharedRuntimeProbe>,
|
|
387
389
|
opts: { coldReceipt?: unknown } = {},
|
|
388
390
|
): Promise<string | null> => {
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
+
const targetRecord = recs.find((rec) => rec.session_id === id)
|
|
392
|
+
const targetHarness = harnessId ? harnessById(harnessId) : null
|
|
393
|
+
const exactKey = targetHarness?.targetDescriptorKey?.({ session: id, harnessSessionId: targetRecord?.harness_session_id }) ?? null
|
|
394
|
+
const targetDescriptors = targetHarness?.sharedRuntimes?.(runtimeRoot()) ?? []
|
|
395
|
+
if (targetHarness && targetDescriptors.length === 0) return null
|
|
396
|
+
if (targetRecord?.harness_session_id && targetHarness?.targetDescriptorKey && targetDescriptors.length && !exactKey)
|
|
397
|
+
return `${targetHarness.id} target has no exact shared-runtime generation binding`
|
|
398
|
+
const allowed = targetHarness
|
|
399
|
+
? new Set(exactKey ? [exactKey] : targetDescriptors.map((descriptor) => descriptor.key))
|
|
391
400
|
: null
|
|
392
401
|
for (const [key, entry] of sharedDescriptors(recs, true)) {
|
|
393
402
|
if (allowed && !allowed.has(key)) continue
|
package/spec-cli/src/index.ts
CHANGED
|
@@ -7,37 +7,38 @@ import { cors } from 'hono/cors'
|
|
|
7
7
|
import { etag } from 'hono/etag'
|
|
8
8
|
import { createNodeWebSocket } from '@hono/node-ws'
|
|
9
9
|
import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig } from './specs.js'
|
|
10
|
-
import { issuesEnabled,
|
|
11
|
-
import { closeIssue, createIssue, findIssue, issueStores, mergedIssues, promote
|
|
10
|
+
import { issuesEnabled, resolveRemark, retractRemark } from './localIssues.js'
|
|
11
|
+
import { closeIssue, createIssue, findIssue, issueStores, mergedIssues, promote } from './issues.js'
|
|
12
|
+
import { remarkWithLoopIn, replyIssueWithLoopIn } from './loop-in.js'
|
|
12
13
|
import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
|
|
13
14
|
import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
|
|
14
|
-
import {
|
|
15
|
+
import { summarizeLoopIn } from './mentions.js'
|
|
15
16
|
import { resolveLayout, mainBranch } from './layout.js'
|
|
16
17
|
import { getBoardJson } from './graphCache.js'
|
|
17
18
|
import { boardStream, closeBoardFileWatchers, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
|
|
18
19
|
import { gitA, gitTry, repoRoot } from './git.js'
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
20
|
+
import { cockpitReview } from './cockpit.js'
|
|
21
|
+
import { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, captureSessionResult, sessionPrompt, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, superviseDelivery, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
|
|
22
|
+
import { readTimeline } from './session-timeline.js'
|
|
21
23
|
import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
|
|
22
24
|
import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
|
|
23
25
|
import { putBlob } from '../../spec-eval/src/cache.js'
|
|
24
26
|
import { fileHumanReading } from '../../spec-eval/src/filing.js'
|
|
25
27
|
import { fileHumanOk } from '../../spec-eval/src/humanok.js'
|
|
26
28
|
import { buildExportModel, renderExportHtml, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
|
|
27
|
-
import {
|
|
29
|
+
import { appendUpload, cancelUpload, completeUpload, createUpload, evidenceMaxBytes, startUploadReaper, UploadError, uploadStatus } from './uploads.js'
|
|
28
30
|
import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges, type Viewer } from './pty-bridge.js'
|
|
29
31
|
import { installProcessGuards } from './resilience.js'
|
|
30
32
|
import { resolveProjectIdentity } from './project-identity.js'
|
|
31
33
|
import { evalDetailReview, evalsReview, issuesReview } from './reviews.js'
|
|
32
34
|
import { collectResourceReport, ResourceConflict } from './host-resources.js'
|
|
33
|
-
import { exactProcessIdentity, maintenanceErrorPayload, sessionMaintenance, SessionMaintenanceError, type Authorization } from './session-maintenance.js'
|
|
34
|
-
import { readBackendInstanceRecords } from './runtime-ownership.js'
|
|
35
35
|
|
|
36
36
|
// last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
|
|
37
37
|
// self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
|
|
38
38
|
installProcessGuards()
|
|
39
39
|
|
|
40
40
|
const app = new Hono()
|
|
41
|
+
startUploadReaper()
|
|
41
42
|
app.use('/api/*', cors())
|
|
42
43
|
app.onError((error, c) => {
|
|
43
44
|
if (error instanceof SessionEvalUnavailableError) return c.json({ error: error.message }, 503)
|
|
@@ -46,7 +47,6 @@ app.onError((error, c) => {
|
|
|
46
47
|
// stack would hide exactly the sentence the human needs.
|
|
47
48
|
if (error instanceof SessionRecordUnusable) return c.json({ error: error.message, code: error.code }, 409)
|
|
48
49
|
if (error instanceof ResourceConflict) return c.json({ error: error.message, code: error.code }, 409)
|
|
49
|
-
if (error instanceof SessionMaintenanceError) return c.json(maintenanceErrorPayload(error)!, 409)
|
|
50
50
|
console.error(error)
|
|
51
51
|
return c.text('Internal Server Error', 500)
|
|
52
52
|
})
|
|
@@ -190,7 +190,7 @@ app.get('/api/evidence/:hash', (c) => {
|
|
|
190
190
|
app.post('/api/evidence', async (c) => {
|
|
191
191
|
const buf = Buffer.from(await c.req.arrayBuffer())
|
|
192
192
|
if (buf.length === 0) return c.json({ error: 'empty evidence' }, 400)
|
|
193
|
-
if (buf.length >
|
|
193
|
+
if (buf.length > evidenceMaxBytes()) return c.json({ error: 'evidence too large' }, 413)
|
|
194
194
|
return c.json({ hash: putBlob(buf) }, 201)
|
|
195
195
|
})
|
|
196
196
|
// the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
|
|
@@ -249,9 +249,8 @@ app.get('/api/issues/:id', (c) => {
|
|
|
249
249
|
})
|
|
250
250
|
// the WRITE surface ([[local-issues]] / [[issues-view]]) — the human reply path, STORE-ROUTED through the one
|
|
251
251
|
// reply verb ([[issues]] replyIssue): a local id git-commits to the trunk store, a forge id ('github#N')
|
|
252
|
-
// posts a REAL comment through the driver
|
|
253
|
-
//
|
|
254
|
-
// server owns its freshness: a forge write forces the resident slice's read-back before answering, so the
|
|
252
|
+
// posts a REAL comment through the driver. @session remains prose in either store; it never sends or spawns.
|
|
253
|
+
// The server owns its freshness: a forge write forces the resident slice's read-back before answering, so the
|
|
255
254
|
// reload that follows shows the comment. Honor the on/off switch: 403 when the feature is OFF; an unknown
|
|
256
255
|
// local thread → 404; a failed forge write → 502 with the driver's own message (fail loud, never queued).
|
|
257
256
|
app.post('/api/issues/:id/reply', async (c) => {
|
|
@@ -268,10 +267,10 @@ app.post('/api/issues/:id/reply', async (c) => {
|
|
|
268
267
|
const node = id.includes('#')
|
|
269
268
|
? mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id)).find((i) => i.id === id)?.nodes[0] ?? null
|
|
270
269
|
: null
|
|
271
|
-
const r = await
|
|
270
|
+
const r = await replyIssueWithLoopIn(id, text, { author: 'human', node, evidence })
|
|
272
271
|
if (r.store !== 'local') await refreshForgeNow()
|
|
273
272
|
notifyBoardChanged('full') // atomic with persistence — see the /api/remarks block below
|
|
274
|
-
return c.json({ ok: true, replies: r.replies, url: r.url, outcomes:
|
|
273
|
+
return c.json({ ok: true, replies: r.replies, url: r.url, outcomes: summarizeLoopIn(r.loopIn) })
|
|
275
274
|
} catch (e) {
|
|
276
275
|
const msg = String((e as Error).message || e)
|
|
277
276
|
return c.json({ error: msg }, id.includes('#') ? 502 : 404)
|
|
@@ -306,7 +305,7 @@ app.post('/api/issues', async (c) => {
|
|
|
306
305
|
const r = await createIssue(concern, { store, nodes, body: postBody, evidence, author: 'human' })
|
|
307
306
|
if (r.store !== 'local') await refreshForgeNow()
|
|
308
307
|
notifyBoardChanged('full') // atomic with persistence — see the /api/remarks block below
|
|
309
|
-
return c.json({ ok: true, id: r.id, store: r.store, url: r.url
|
|
308
|
+
return c.json({ ok: true, id: r.id, store: r.store, url: r.url }, 201)
|
|
310
309
|
} catch (e) {
|
|
311
310
|
return c.json({ error: String((e as Error).message || e) }, store === 'local' ? 500 : 502)
|
|
312
311
|
}
|
|
@@ -359,9 +358,9 @@ app.post('/api/remarks', async (c) => {
|
|
|
359
358
|
: { issue: typeof body?.issue === 'string' ? body.issue : undefined }
|
|
360
359
|
const codeSha = typeof body?.codeSha === 'string' ? body.codeSha : undefined
|
|
361
360
|
try {
|
|
362
|
-
const r = await
|
|
361
|
+
const r = await remarkWithLoopIn(host, text, { codeSha, author: 'human', evidence })
|
|
363
362
|
notifyBoardChanged('full')
|
|
364
|
-
return c.json({ ok: true, ref: r.ref, rid: r.rid, codeSha: r.codeSha, outcomes:
|
|
363
|
+
return c.json({ ok: true, ref: r.ref, rid: r.rid, codeSha: r.codeSha, outcomes: summarizeLoopIn(r.loopIn) }, 201)
|
|
365
364
|
} catch (e) {
|
|
366
365
|
return c.json({ error: String((e as Error).message || e) }, 400)
|
|
367
366
|
}
|
|
@@ -389,17 +388,52 @@ app.get('/api/slash-commands', (c) => {
|
|
|
389
388
|
return c.json(h.slashCommands())
|
|
390
389
|
})
|
|
391
390
|
|
|
392
|
-
|
|
393
|
-
|
|
391
|
+
function uploadFailure(error: unknown): Response {
|
|
392
|
+
if (!(error instanceof UploadError)) throw error
|
|
393
|
+
const body: { error: string; offset?: number } = { error: error.message }
|
|
394
|
+
if (error.offset != null) body.offset = error.offset
|
|
395
|
+
return new Response(JSON.stringify(body), { status: error.status, headers: { 'content-type': 'application/json' } })
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// One offset protocol for every attachment. Chunks stream through the existing /api proxy and stage only on
|
|
399
|
+
// the worker machine; completion is the one boundary that makes a prompt-visible absolute path exist.
|
|
394
400
|
app.post('/api/uploads', async (c) => {
|
|
395
|
-
const body = await c.req.
|
|
396
|
-
const file = body['file']
|
|
397
|
-
if (!(file instanceof File) || file.size === 0) return c.json({ error: 'no file' }, 400)
|
|
398
|
-
if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: 'file too large' }, 413)
|
|
401
|
+
const body = await c.req.json().catch(() => null) as { name?: unknown; size?: unknown } | null
|
|
399
402
|
try {
|
|
400
|
-
return c.json(
|
|
401
|
-
} catch (
|
|
402
|
-
return
|
|
403
|
+
return c.json(createUpload(body?.name, body?.size), 201)
|
|
404
|
+
} catch (error) {
|
|
405
|
+
return uploadFailure(error)
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
app.get('/api/uploads/:id', (c) => {
|
|
409
|
+
try {
|
|
410
|
+
return c.json(uploadStatus(c.req.param('id')))
|
|
411
|
+
} catch (error) {
|
|
412
|
+
return uploadFailure(error)
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
app.patch('/api/uploads/:id', async (c) => {
|
|
416
|
+
try {
|
|
417
|
+
return c.json(await appendUpload(
|
|
418
|
+
c.req.param('id'), Number(c.req.header('upload-offset')), c.req.raw.body, c.req.header('content-length'),
|
|
419
|
+
))
|
|
420
|
+
} catch (error) {
|
|
421
|
+
return uploadFailure(error)
|
|
422
|
+
}
|
|
423
|
+
})
|
|
424
|
+
app.post('/api/uploads/:id/complete', (c) => {
|
|
425
|
+
try {
|
|
426
|
+
return c.json({ path: completeUpload(c.req.param('id')) }, 201)
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return uploadFailure(error)
|
|
429
|
+
}
|
|
430
|
+
})
|
|
431
|
+
app.delete('/api/uploads/:id', (c) => {
|
|
432
|
+
try {
|
|
433
|
+
cancelUpload(c.req.param('id'))
|
|
434
|
+
return c.body(null, 204)
|
|
435
|
+
} catch (error) {
|
|
436
|
+
return uploadFailure(error)
|
|
403
437
|
}
|
|
404
438
|
})
|
|
405
439
|
|
|
@@ -407,60 +441,6 @@ app.post('/api/uploads', async (c) => {
|
|
|
407
441
|
// forward keystrokes, and close.
|
|
408
442
|
app.get('/api/sessions', async (c) => c.json(await listSessions(c.req.query('all') === '1' || c.req.query('all') === 'true')))
|
|
409
443
|
app.get('/api/resources', async (c) => c.json(await collectResourceReport()))
|
|
410
|
-
const maintenance = sessionMaintenance()
|
|
411
|
-
const maintenanceToken = (header: (name: string) => string | undefined) => header('x-spexcode-session-maintenance')?.trim() || ''
|
|
412
|
-
const maintenanceLeaseOwner = () => {
|
|
413
|
-
const instanceId = process.env.SPEXCODE_INSTANCE_ID?.trim()
|
|
414
|
-
const record = instanceId ? readBackendInstanceRecords(repoRoot()).find((candidate) => candidate.instanceId === instanceId) : null
|
|
415
|
-
const exact = record ? exactProcessIdentity(record.pid) : null
|
|
416
|
-
if (!instanceId || !record || !exact || exact.startToken !== record.startToken)
|
|
417
|
-
throw new SessionMaintenanceError('maintenance_identity_unknown', 'cannot prove the exact live backend supervisor generation')
|
|
418
|
-
return { instanceId, pid: record.pid, startToken: record.startToken }
|
|
419
|
-
}
|
|
420
|
-
const operationAuthorization = async (
|
|
421
|
-
header: (name: string) => string | undefined,
|
|
422
|
-
operation: { op: 'stop'; sessionId: string } | { op: 'resume'; sessionId: string; force: boolean },
|
|
423
|
-
): Promise<Authorization | undefined> => maintenance.authorizeHttpOperation({
|
|
424
|
-
authenticated: true,
|
|
425
|
-
projectMatches: true,
|
|
426
|
-
headers: { [maintenance.headerName]: maintenanceToken(header) },
|
|
427
|
-
operation,
|
|
428
|
-
})
|
|
429
|
-
app.get('/api/session-maintenance', (c) => c.json(maintenance.readState()))
|
|
430
|
-
app.post('/api/session-maintenance/acquire', async (c) => {
|
|
431
|
-
const body = await c.req.json().catch(() => ({}))
|
|
432
|
-
const lease = await maintenance.acquireLease({
|
|
433
|
-
capabilities: Array.isArray(body?.capabilities) ? body.capabilities : body?.capabilities,
|
|
434
|
-
owner: maintenanceLeaseOwner(),
|
|
435
|
-
ttlMs: Number(body?.ttlMs),
|
|
436
|
-
waitMs: Number(body?.waitMs),
|
|
437
|
-
})
|
|
438
|
-
return c.json(lease, lease.state === 'active' ? 201 : 202)
|
|
439
|
-
})
|
|
440
|
-
app.post('/api/session-maintenance/heartbeat', async (c) => {
|
|
441
|
-
const body = await c.req.json().catch(() => ({}))
|
|
442
|
-
await maintenance.heartbeatLease({ token: maintenanceToken(c.req.header.bind(c.req)), epoch: Number(body?.epoch), ttlMs: Number(body?.ttlMs) })
|
|
443
|
-
return c.json({ ok: true, ...maintenance.readState() })
|
|
444
|
-
})
|
|
445
|
-
app.post('/api/session-maintenance/release', async (c) => {
|
|
446
|
-
const body = await c.req.json().catch(() => ({}))
|
|
447
|
-
await maintenance.releaseLease({ token: maintenanceToken(c.req.header.bind(c.req)), epoch: Number(body?.epoch) })
|
|
448
|
-
return c.json({ ok: true })
|
|
449
|
-
})
|
|
450
|
-
// edges derived live from `spex session watch` monitors (A→B = agent A is watching B), not a stored subscription;
|
|
451
|
-
// watch/unwatch register + heartbeat. A literal `edges` segment so it never collides with the `:id` routes.
|
|
452
|
-
app.get('/api/sessions/edges', async (c) => c.json(await sessionGraph()))
|
|
453
|
-
app.post('/api/sessions/edges/watch', async (c) => {
|
|
454
|
-
const b = await c.req.json().catch(() => ({}))
|
|
455
|
-
const selectors = Array.isArray(b?.selectors) ? b.selectors.map(String) : []
|
|
456
|
-
const ok = registerWatch(String(b?.token || ''), String(b?.watcher || ''), selectors, Number(b?.ttlMs) || undefined)
|
|
457
|
-
return c.json({ ok }, ok ? 200 : 400)
|
|
458
|
-
})
|
|
459
|
-
app.post('/api/sessions/edges/unwatch', async (c) => {
|
|
460
|
-
const b = await c.req.json().catch(() => ({}))
|
|
461
|
-
const ok = deregisterWatch(String(b?.token || ''))
|
|
462
|
-
return c.json({ ok }, ok ? 200 : 404)
|
|
463
|
-
})
|
|
464
444
|
app.post('/api/sessions', async (c) => {
|
|
465
445
|
const requestKey = c.req.header('idempotency-key') || randomUUID()
|
|
466
446
|
const controller = new AbortController()
|
|
@@ -488,7 +468,7 @@ app.post('/api/sessions', async (c) => {
|
|
|
488
468
|
// one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
|
|
489
469
|
// dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
|
|
490
470
|
app.get('/api/sessions/:id/review', async (c) => {
|
|
491
|
-
const r = await
|
|
471
|
+
const r = await cockpitReview(c.req.param('id'))
|
|
492
472
|
return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
|
|
493
473
|
})
|
|
494
474
|
// The self-contained HTML is the sole full-model transport exception. Interactive rows, including the CLI,
|
|
@@ -533,9 +513,7 @@ app.get('/api/sessions/:id', async (c) => {
|
|
|
533
513
|
app.post('/api/sessions/:id/resume', async (c) => {
|
|
534
514
|
const body = await c.req.json().catch(() => ({} as { force?: boolean }))
|
|
535
515
|
const force = body?.force === true || c.req.query('force') === '1'
|
|
536
|
-
const
|
|
537
|
-
const authorization = await operationAuthorization(c.req.header.bind(c.req), { op: 'resume', sessionId, force })
|
|
538
|
-
const r = await resumeSession(sessionId, { force, authorization })
|
|
516
|
+
const r = await resumeSession(c.req.param('id'), { force })
|
|
539
517
|
return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404))
|
|
540
518
|
})
|
|
541
519
|
// a dispatch to the session's own agent (it runs the merge), never a server merge — the server never touches
|
|
@@ -623,22 +601,21 @@ app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
|
|
|
623
601
|
}
|
|
624
602
|
}))
|
|
625
603
|
// ONE input route, `kind` the discriminator — the transport split is an implementation fact, not API surface.
|
|
626
|
-
// kind:"text" (
|
|
627
|
-
//
|
|
628
|
-
//
|
|
604
|
+
// kind:"text" (`spex session send`, the server-side merge dispatch) appends the prompt to the
|
|
605
|
+
// target timeline, then best-effort pokes its adapter. A dead channel delays context injection but does not
|
|
606
|
+
// change the successful append response; 502 means the record rejected the write.
|
|
629
607
|
// kind:"keys" is the LAST-RESORT raw face (`spex session send --keys`): an ORDERED BATCH of
|
|
630
608
|
// nav-mode key tokens over tmux send-keys, delivered in array order so tap order survives
|
|
631
609
|
// ([[nav-mode-key-ordering]]); unstable by nature — callers try a plain text send first. An unknown kind is a
|
|
632
610
|
// loud 400, never a guessed channel.
|
|
633
611
|
app.post('/api/sessions/:id/input', async (c) => {
|
|
634
612
|
const body = await c.req.json().catch(() => ({}))
|
|
635
|
-
if (body?.kind === 'text') {
|
|
613
|
+
if (body?.kind === 'text' || body?.kind === 'command') {
|
|
636
614
|
// `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
|
|
637
|
-
// edge ([[
|
|
615
|
+
// edge ([[session-timeline]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
|
|
638
616
|
// terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
|
|
639
617
|
const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined, {
|
|
640
618
|
...(body?.replyVia === 'note' ? { replyVia: 'note' as const } : {}),
|
|
641
|
-
...(typeof body?.deliveryId === 'string' && body.deliveryId ? { deliveryId: body.deliveryId } : {}),
|
|
642
619
|
})
|
|
643
620
|
return c.json(r, r.ok ? 200 : 502)
|
|
644
621
|
}
|
|
@@ -647,14 +624,13 @@ app.post('/api/sessions/:id/input', async (c) => {
|
|
|
647
624
|
const ok = await rawKey(c.req.param('id'), keys)
|
|
648
625
|
return c.json({ ok }, ok ? 200 : 404)
|
|
649
626
|
}
|
|
650
|
-
return c.json({ error: 'input needs kind: "text" | "keys"' }, 400)
|
|
627
|
+
return c.json({ error: 'input needs kind: "text" | "command" | "keys"' }, 400)
|
|
651
628
|
})
|
|
652
629
|
// soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
|
|
653
630
|
// removes the worktree. {ok:false} = no such session.
|
|
654
631
|
app.post('/api/sessions/:id/stop', async (c) => {
|
|
655
632
|
const sessionId = c.req.param('id')
|
|
656
|
-
const
|
|
657
|
-
const ok = await stopSession(sessionId, { authorization })
|
|
633
|
+
const ok = await stopSession(sessionId)
|
|
658
634
|
return c.json(ok ? { ok: true } : { ok: false, error: `no stop transition was committed for session ${sessionId}` }, ok ? 200 : 404)
|
|
659
635
|
})
|
|
660
636
|
app.post('/api/sessions/:id/interrupt', async (c) => {
|
|
@@ -724,7 +700,7 @@ injectWebSocket(server)
|
|
|
724
700
|
superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
|
|
725
701
|
superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
|
|
726
702
|
superviseTurnFailures() // reconcile adapter-owned native failure subscriptions across backend replacement
|
|
727
|
-
|
|
703
|
+
superviseDelivery() // hand over messages an earlier pass could not ([[delivery-queue]]): the retry half of dispatch
|
|
728
704
|
console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
|
|
729
705
|
|
|
730
706
|
let graphWatchersClosed = false
|