spexcode 0.5.7 → 0.5.9

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 (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +199 -60
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +42 -30
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -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]] · @new / @new:<launcher> work in ANY prompt, issue, or remark body —
29
- text passed as a CLI arg included. [[node]] names the topic node; @session hands the text to that live agent;
30
- @new spawns a fresh worker on the thread's node (bare = configured default; :<launcher> = that named profile).`
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=5]',
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 <SEL> [--timeout S=1200] [--interval S=2] [--idle]',
50
- `EDGE-TRIGGERED wait on one session. Prints the current status immediately (stderr), then
51
- exits 0 only when it OBSERVES
52
- the session TRANSITION from a non-actionable status into an actionable one, printing the
53
- observed path on stdout (e.g. working→review read the LAST token as the status reached).
54
- It NEVER returns just because the session is actionable ALREADY for "what is it right NOW"
55
- use \`session ls\` / \`session review\` instead. --timeout is the guaranteed exit (code 1,
56
- observed path on stderr).`, ['selector']],
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 inbox, 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 and fails loud when dispatch is dead. --keys is the LAST RESORT:
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>', 'Retire the session and its worktree.', ['selector', 'project-bound']],
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
- maintain: [[
76
- 'spex session maintain --allow-stop <SEL> [--allow-resume <SEL>[:force]] -- <command> [args…]',
77
- 'spex session maintain --status',
78
- ], `Hold one project maintenance lease around a bounded operator command. Only the exact one-shot
79
- stop/resume plan is brokered; ordinary session writes stay closed. The bearer never prints or enters
80
- argv/environment. --status is the sanitized read-only lease view.`, ['selector', 'project-bound']],
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', 'maintain'] },
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/maintain/merge) are PROJECT-BOUND: a backend serving
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 allowed = harnessId
390
- ? new Set((harnessById(harnessId).sharedRuntimes?.(runtimeRoot()) ?? []).map((descriptor) => descriptor.key))
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
@@ -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, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
11
- import { closeIssue, createIssue, findIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
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 { summarize } from './mentions.js'
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 { listSessions, sendText, interruptSession, rawKey, stopSession, closeSession, quarantineCorruptRecord, restoreQuarantinedRecord, archiveSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, superviseTurnFailures, SessionRecordUnusable, TMUX_SOCK } from './sessions.js'
20
- import { superviseTimeline, readTimeline } from './session-timeline.js'
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, 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 { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
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 > MAX_UPLOAD_BYTES) return c.json({ error: 'evidence too large' }, 413)
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; either way the text's @-mentions dispatch (a human summons an
253
- // agent from the issues page). `outcomes` is the one-line @-dispatch summary the dashboard echoes. The
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 replyIssue(id, text, { author: 'human', node, evidence })
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: summarize(r.outcomes, r.loopIn) })
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, outcomes: summarize(r.outcomes) }, 201)
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 remarkOnHost(host, text, { codeSha, author: 'human', evidence })
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: summarize(r.outcomes, r.loopIn) }, 201)
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
- // write a pasted/dropped/picked file to this (worker) machine's /tmp and return its absolute path for the
393
- // client to splice into the prompt. Fail-loud: no/empty file → 400, over the size cap → 413, write error → 500.
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.parseBody().catch(() => ({} as Record<string, string | File>))
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({ path: await saveUpload(file) }, 201)
401
- } catch (e) {
402
- return c.json({ error: String((e as Error)?.message || e) }, 500)
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 reviewPayload(c.req.param('id'))
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 sessionId = c.req.param('id')
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" (Command Box, `spex session send`, the server-side merge dispatch) injects a whole prompt
627
- // through the rendezvous control socket socket-only + fail-loud: a prompt the agent doesn't confirm
628
- // accepting returns 502 with the reason (never a silent 200), so a dead dispatch is seen, not a false success.
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 ([[comms-edge]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
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 authorization = await operationAuthorization(c.req.header.bind(c.req), { op: 'stop', sessionId })
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,6 @@ 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
- superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
728
703
  console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
729
704
 
730
705
  let graphWatchersClosed = false