spexcode 0.4.0 → 0.4.3

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 (111) hide show
  1. package/README.md +37 -17
  2. package/package.json +9 -5
  3. package/spec-cli/README.md +23 -14
  4. package/spec-cli/hooks/dispatch.sh +7 -5
  5. package/spec-cli/hooks/harness.sh +19 -8
  6. package/spec-cli/src/anchors.ts +163 -30
  7. package/spec-cli/src/cli.ts +97 -42
  8. package/spec-cli/src/client.ts +34 -11
  9. package/spec-cli/src/doctor.ts +174 -36
  10. package/spec-cli/src/gateway-auth.ts +194 -0
  11. package/spec-cli/src/gateway-hub.ts +360 -0
  12. package/spec-cli/src/gateway.ts +31 -16
  13. package/spec-cli/src/git.ts +12 -5
  14. package/spec-cli/src/graph.ts +53 -36
  15. package/spec-cli/src/graphStream.ts +265 -34
  16. package/spec-cli/src/guide.ts +157 -76
  17. package/spec-cli/src/harness-select.ts +20 -6
  18. package/spec-cli/src/harness.ts +266 -67
  19. package/spec-cli/src/help.ts +84 -41
  20. package/spec-cli/src/host.ts +610 -0
  21. package/spec-cli/src/identity-presets.d.ts +13 -0
  22. package/spec-cli/src/identity-presets.js +128 -0
  23. package/spec-cli/src/index.ts +163 -86
  24. package/spec-cli/src/init.ts +53 -25
  25. package/spec-cli/src/layout.ts +5 -5
  26. package/spec-cli/src/lint.ts +115 -169
  27. package/spec-cli/src/login-page.ts +12 -5
  28. package/spec-cli/src/materialize.ts +39 -13
  29. package/spec-cli/src/mentions.ts +16 -11
  30. package/spec-cli/src/opencode.ts +145 -0
  31. package/spec-cli/src/pi-harness.ts +124 -0
  32. package/spec-cli/src/project-identity.ts +73 -0
  33. package/spec-cli/src/pty-bridge.ts +321 -521
  34. package/spec-cli/src/pty-helper.mjs +86 -0
  35. package/spec-cli/src/reaper.ts +16 -8
  36. package/spec-cli/src/reviewSnapshot.ts +23 -0
  37. package/spec-cli/src/reviews.ts +347 -0
  38. package/spec-cli/src/session-timeline.ts +162 -0
  39. package/spec-cli/src/sessions.ts +210 -63
  40. package/spec-cli/src/shim-runtime.ts +169 -0
  41. package/spec-cli/src/slash-commands.ts +76 -0
  42. package/spec-cli/src/source-files.ts +79 -0
  43. package/spec-cli/src/specs.ts +47 -24
  44. package/spec-cli/src/supervise.ts +37 -15
  45. package/spec-cli/src/tree.ts +8 -8
  46. package/spec-cli/src/uninstall.ts +56 -23
  47. package/spec-cli/templates/hooks/prepare-commit-msg +32 -13
  48. package/spec-cli/templates/spec/project/.plugins/{extract → commands/extract}/spec.md +24 -18
  49. package/spec-cli/templates/spec/project/.plugins/{regroup → commands/regroup}/spec.md +3 -3
  50. package/spec-cli/templates/spec/project/.plugins/commands/rename/spec.md +11 -0
  51. package/spec-cli/templates/spec/project/.plugins/commands/spec.md +30 -0
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +8 -0
  53. package/spec-cli/templates/spec/project/.plugins/commands/tidy/spec.md +43 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +2 -0
  56. package/spec-cli/templates/spec/project/.plugins/core/session-fail/spec.md +1 -1
  57. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec-first.sh +28 -39
  58. package/spec-cli/templates/spec/project/.plugins/core/spec-first/spec.md +5 -3
  59. package/spec-cli/templates/spec/project/.plugins/core/spec-of-file/spec.md +1 -1
  60. package/spec-cli/templates/spec/project/.plugins/core/spec.md +2 -2
  61. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/spec.md +7 -1
  62. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -2
  63. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +5 -5
  64. package/spec-cli/templates/spec/project/.plugins/prompts/spec.md +10 -6
  65. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +62 -0
  66. package/spec-cli/templates/spec/project/.plugins/skills/spec.md +19 -0
  67. package/spec-cli/templates/spec/project/.plugins/spec.md +17 -12
  68. package/spec-cli/templates/spec/project/spec.md +6 -5
  69. package/spec-cli/templates/spexcode.json +4 -2
  70. package/spec-dashboard/dist/assets/Dashboard-CiHh-gLD.js +27 -0
  71. package/spec-dashboard/dist/assets/EvalsPage-DmiX3rdU.js +2 -0
  72. package/spec-dashboard/dist/assets/IssuesPage-CIbVGRUJ.js +1 -0
  73. package/spec-dashboard/dist/assets/MobileApp-D-N9_eh0.js +2 -0
  74. package/spec-dashboard/dist/assets/Modal-DHMzSFJ4.js +1 -0
  75. package/spec-dashboard/dist/assets/PageScroll-C15adEYI.js +1 -0
  76. package/spec-dashboard/dist/assets/ProjectsPage-sQpzglp5.js +1 -0
  77. package/spec-dashboard/dist/assets/SessionInterface-6GBZ9nXN.css +32 -0
  78. package/spec-dashboard/dist/assets/SessionInterface-B8pGU7Rg.js +39 -0
  79. package/spec-dashboard/dist/assets/SessionWindow-CuDO_67z.js +13 -0
  80. package/spec-dashboard/dist/assets/Settings-C_N1wX1f.js +1 -0
  81. package/spec-dashboard/dist/assets/bindings-BC9vqpYU.js +1 -0
  82. package/spec-dashboard/dist/assets/index-DmWbmvCq.js +41 -0
  83. package/spec-dashboard/dist/assets/index-GGIVdKwH.css +1 -0
  84. package/spec-dashboard/dist/assets/terminalFont-DmTPNdbU.js +1 -0
  85. package/spec-dashboard/dist/index.html +10 -10
  86. package/spec-dashboard/src/reviewFilters.js +279 -0
  87. package/spec-dashboard/src/reviewQuery.js +174 -0
  88. package/spec-dashboard/src/session.js +167 -0
  89. package/spec-eval/src/cli.ts +80 -45
  90. package/spec-eval/src/evaltab.ts +4 -3
  91. package/spec-eval/src/humanok.ts +1 -1
  92. package/spec-eval/src/matrix.ts +693 -0
  93. package/spec-eval/src/scenarios.ts +116 -4
  94. package/spec-eval/src/sessioneval.ts +726 -66
  95. package/spec-cli/src/migrate-table.ts +0 -398
  96. package/spec-cli/src/migrate.ts +0 -386
  97. package/spec-cli/templates/spec/project/.plugins/distill/spec.md +0 -74
  98. package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +0 -8
  99. package/spec-cli/templates/spec/project/.plugins/tidy/spec.md +0 -25
  100. package/spec-dashboard/dist/assets/Dashboard-CTcH2eW9.js +0 -27
  101. package/spec-dashboard/dist/assets/EvalsPage-CJNKwHLN.js +0 -2
  102. package/spec-dashboard/dist/assets/FoldToggle-CVFbBpyW.js +0 -1
  103. package/spec-dashboard/dist/assets/IssuesPage-kULjonqj.js +0 -1
  104. package/spec-dashboard/dist/assets/MobileApp-B0ZJju8K.js +0 -1
  105. package/spec-dashboard/dist/assets/SessionInterface-BRKJqU2U.js +0 -71
  106. package/spec-dashboard/dist/assets/SessionInterface-DYP7pi_n.css +0 -32
  107. package/spec-dashboard/dist/assets/SessionWindow-CDhEL7wO.js +0 -9
  108. package/spec-dashboard/dist/assets/Settings-BL6FV_8S.js +0 -1
  109. package/spec-dashboard/dist/assets/index-DmQsNYKK.css +0 -1
  110. package/spec-dashboard/dist/assets/index-DulGPk6A.js +0 -41
  111. /package/spec-cli/templates/spec/project/.plugins/{distill → skills/distill}/digest.mjs +0 -0
@@ -0,0 +1,128 @@
1
+ // One browser-safe identity registry shared by backend validation and every dashboard projection.
2
+ // Geometry is data so the React renderer and favicon serializer cannot drift into separate drawings.
3
+
4
+ export const DEFAULT_PROJECT_ICON = 'spexcode'
5
+ export const DEFAULT_GATEWAY_ICON = 'gateway'
6
+
7
+ export const IDENTITY_PRESETS = Object.freeze([
8
+ {
9
+ id: 'spexcode', label: 'SpexCode', bg: '#166534', fg: '#f0fdf4',
10
+ shapes: [
11
+ { tag: 'circle', cx: 12, cy: 6.2, r: 2.2 },
12
+ { tag: 'circle', cx: 6.2, cy: 17.5, r: 2 },
13
+ { tag: 'circle', cx: 17.8, cy: 17.5, r: 2 },
14
+ { tag: 'path', d: 'M12 8.4v2.1c0 2.2-5.8 1.8-5.8 5' },
15
+ { tag: 'path', d: 'M12 10.5c0 2.2 5.8 1.8 5.8 5' },
16
+ ],
17
+ },
18
+ {
19
+ id: 'gateway', label: 'Gateway', bg: '#155e75', fg: '#ecfeff',
20
+ shapes: [
21
+ { tag: 'path', d: 'm12 3 8 4.2-8 4.2-8-4.2Z' },
22
+ { tag: 'path', d: 'm4 11.2 8 4.2 8-4.2' },
23
+ { tag: 'path', d: 'm4 15.2 8 4.2 8-4.2' },
24
+ ],
25
+ },
26
+ {
27
+ id: 'mdi:rocket-launch', label: 'Rocket', bg: '#9f1239', fg: '#fff1f2',
28
+ shapes: [
29
+ { tag: 'path', d: 'M14.5 5.2c2.2-2.2 4.8-2 5.3-1.8.2.5.4 3.1-1.8 5.3l-5.5 5.5-4.2-4.2Z' },
30
+ { tag: 'path', d: 'm11.2 6.8-4.1.6-2.5 2.5 4.1.7' },
31
+ { tag: 'path', d: 'm16.4 12-1 4.7-2.5 2.5-.7-4.1' },
32
+ { tag: 'circle', cx: 16.1, cy: 7.1, r: 1.2 },
33
+ { tag: 'path', d: 'M7.6 14.6c-2.2.4-3.4 1.6-3.6 3.8 2.2-.2 3.4-1.4 3.8-3.6' },
34
+ ],
35
+ },
36
+ {
37
+ id: 'compass', label: 'Compass', bg: '#1d4ed8', fg: '#eff6ff',
38
+ shapes: [
39
+ { tag: 'circle', cx: 12, cy: 12, r: 8.5 },
40
+ { tag: 'path', d: 'm15.2 8.8-1.8 4.6-4.6 1.8 1.8-4.6Z' },
41
+ ],
42
+ },
43
+ {
44
+ id: 'terminal', label: 'Terminal', bg: '#3f3f46', fg: '#fafafa',
45
+ shapes: [
46
+ { tag: 'rect', x: 3.5, y: 4.5, width: 17, height: 15, rx: 2 },
47
+ { tag: 'path', d: 'm7 9 3 3-3 3' },
48
+ { tag: 'path', d: 'M12.5 15H17' },
49
+ ],
50
+ },
51
+ {
52
+ id: 'package', label: 'Package', bg: '#6d28d9', fg: '#f5f3ff',
53
+ shapes: [
54
+ { tag: 'path', d: 'm12 3 8 4.5v9L12 21l-8-4.5v-9Z' },
55
+ { tag: 'path', d: 'm4.3 7.7 7.7 4.4 7.7-4.4' },
56
+ { tag: 'path', d: 'M12 12.1V21' },
57
+ ],
58
+ },
59
+ {
60
+ id: 'database', label: 'Database', bg: '#a16207', fg: '#fefce8',
61
+ shapes: [
62
+ { tag: 'ellipse', cx: 12, cy: 6, rx: 7.5, ry: 3 },
63
+ { tag: 'path', d: 'M4.5 6v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3V6' },
64
+ { tag: 'path', d: 'M4.5 12v6c0 1.7 3.4 3 7.5 3s7.5-1.3 7.5-3v-6' },
65
+ ],
66
+ },
67
+ {
68
+ id: 'spark', label: 'Spark', bg: '#c2410c', fg: '#fff7ed',
69
+ shapes: [
70
+ { tag: 'path', d: 'm12 3 1.5 5.2L19 10l-5.5 1.8L12 17l-1.5-5.2L5 10l5.5-1.8Z' },
71
+ { tag: 'path', d: 'm18.5 15 .7 2.2 2.3.8-2.3.8-.7 2.2-.7-2.2-2.3-.8 2.3-.8Z' },
72
+ ],
73
+ },
74
+ ])
75
+
76
+ const BY_ID = new Map(IDENTITY_PRESETS.map((preset) => [preset.id, preset]))
77
+ const ICONIFY_ID = /^[a-z0-9-]+[:/][a-z0-9-]+$/i
78
+ const ALIASES = new Map([
79
+ ['rocket', 'mdi:rocket-launch'],
80
+ ['mdi/rocket-launch', 'mdi:rocket-launch'],
81
+ ['layers', 'gateway'],
82
+ ['default', 'spexcode'],
83
+ ])
84
+
85
+ export const IDENTITY_PRESET_IDS = Object.freeze(IDENTITY_PRESETS.map((preset) => preset.id))
86
+
87
+ export function resolvedIdentityIcon(value, fallback = DEFAULT_PROJECT_ICON) {
88
+ const raw = typeof value === 'string' ? value.trim() : ''
89
+ const id = ALIASES.get(raw) || raw
90
+ return id || fallback
91
+ }
92
+
93
+ export function identityPreset(value) {
94
+ const raw = typeof value === 'string' ? value.trim() : ''
95
+ return BY_ID.get(ALIASES.get(raw) || raw) || null
96
+ }
97
+
98
+ export function isIconifyIcon(value) {
99
+ return ICONIFY_ID.test(typeof value === 'string' ? value.trim() : '')
100
+ }
101
+
102
+ export function requireIdentityChoice(value) {
103
+ const raw = typeof value === 'string' ? value.trim() : ''
104
+ const id = ALIASES.get(raw) || raw
105
+ if (BY_ID.has(id)) return id
106
+ if (isIconifyIcon(id)) return id.replace('/', ':')
107
+ throw new Error(`unknown identity icon '${raw}' (choose a preset or Iconify prefix:name)`)
108
+ }
109
+
110
+ function attrs(shape) {
111
+ return Object.entries(shape).filter(([key]) => key !== 'tag')
112
+ .map(([key, value]) => `${key === 'className' ? 'class' : key}="${String(value).replaceAll('&', '&').replaceAll('"', '"')}"`).join(' ')
113
+ }
114
+
115
+ export function identitySvg(value, fallback = DEFAULT_PROJECT_ICON) {
116
+ const preset = identityPreset(value) || identityPreset(fallback)
117
+ const geometry = preset.shapes.map((shape) => `<${shape.tag} ${attrs(shape)}/>`).join('')
118
+ return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><rect x="1" y="1" width="22" height="22" rx="5" fill="${preset.bg}"/><g fill="none" stroke="${preset.fg}" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${geometry}</g></svg>`
119
+ }
120
+
121
+ export function identityFaviconHref(value, fallback = DEFAULT_PROJECT_ICON) {
122
+ const resolved = resolvedIdentityIcon(value, fallback)
123
+ if (identityPreset(resolved)) return `data:image/svg+xml,${encodeURIComponent(identitySvg(resolved, fallback))}`
124
+ if (/^https?:\/\//.test(resolved)) return resolved
125
+ if (isIconifyIcon(resolved)) return `https://api.iconify.design/${resolved.replace(':', '/')}.svg`
126
+ const glyph = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><text x="50" y=".86em" font-size="82" text-anchor="middle">${resolved.replaceAll('&', '&amp;').replaceAll('<', '&lt;')}</text></svg>`
127
+ return `data:image/svg+xml,${encodeURIComponent(glyph)}`
128
+ }
@@ -5,7 +5,7 @@ import { Hono } from 'hono'
5
5
  import { cors } from 'hono/cors'
6
6
  import { etag } from 'hono/etag'
7
7
  import { createNodeWebSocket } from '@hono/node-ws'
8
- import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig } from './specs.js'
8
+ import { loadSpecs, loadSpecsLite, specContent, specHistory, specDiffAt, loadConfig, loadReviewConfig } from './specs.js'
9
9
  import { issuesEnabled, remarkOnHost, resolveRemark, retractRemark } from './localIssues.js'
10
10
  import { closeIssue, createIssue, findIssue, issueStores, mergedIssues, promote, replyIssue } from './issues.js'
11
11
  import { residentForgeState, refreshForgeNow } from '../../spec-forge/src/resident.js'
@@ -13,19 +13,21 @@ import { resolveForgeHost } from '../../spec-forge/src/drivers.js'
13
13
  import { summarize } from './mentions.js'
14
14
  import { resolveLayout, mainBranch } from './layout.js'
15
15
  import { getBoardJson } from './graphCache.js'
16
- import { boardStream, notifyBoardChanged } from './graphStream.js'
16
+ import { boardStream, ensureBoardFileWatchers, notifyBoardChanged } from './graphStream.js'
17
17
  import { gitA, gitTry, repoRoot } from './git.js'
18
- import { newSession, listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, superviseQueue } from './sessions.js'
18
+ import { listSessions, sendText, rawKey, stopSession, closeSession, resumeSession, mergeSession, reviewPayload, captureSessionResult, sessionPrompt, sessionGraph, registerWatch, deregisterWatch, renameSession, setSessionSort, sessionCreateRequest, superviseQueue, TMUX_SOCK } from './sessions.js'
19
+ import { superviseTimeline, readTimeline } from './session-timeline.js'
19
20
  import { defaultHarness, HARNESSES, launcherList, launcherDefault } from './harness.js'
20
21
  import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
21
22
  import { putBlob } from '../../spec-eval/src/cache.js'
22
- import { evalNodes } from '../../spec-eval/src/scenarios.js'
23
23
  import { fileHumanReading } from '../../spec-eval/src/filing.js'
24
24
  import { fileHumanOk } from '../../spec-eval/src/humanok.js'
25
- import { buildExportModel, renderExportHtml, buildSessionEvals } from '../../spec-eval/src/sessioneval.js'
25
+ import { buildExportModel, renderExportHtml, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
26
26
  import { saveUpload, MAX_UPLOAD_BYTES } from './uploads.js'
27
- import { attachViewer, detachViewer, resizeBridge, forwardWheel, superviseBridges, type Viewer } from './pty-bridge.js'
27
+ import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges, type Viewer } from './pty-bridge.js'
28
28
  import { installProcessGuards } from './resilience.js'
29
+ import { resolveProjectIdentity } from './project-identity.js'
30
+ import { evalDetailReview, evalsReview, issuesReview } from './reviews.js'
29
31
 
30
32
  // last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
31
33
  // self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
@@ -33,12 +35,34 @@ installProcessGuards()
33
35
 
34
36
  const app = new Hono()
35
37
  app.use('/api/*', cors())
38
+ app.onError((error, c) => {
39
+ if (error instanceof SessionEvalUnavailableError) return c.json({ error: error.message }, 503)
40
+ console.error(error)
41
+ return c.text('Internal Server Error', 500)
42
+ })
36
43
  const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app })
37
44
 
38
45
  app.get('/', (c) => c.text('spec-cli — GET /api/graph · /api/specs · /api/specs/:id/history · /api/settings · /api/sessions · /api/slash-commands'))
39
46
  // the supervisor's readiness gate (supervise.ts): a bare git-free 200 so a booting child reports ready the
40
47
  // instant Hono is listening. Not under /api/* — loopback-only (supervisor→child), no CORS needed.
41
48
  app.get('/health', (c) => c.text('ok'))
49
+ // @@@ instance identity - who THIS backend is: the serve generation's instanceId (minted by the supervisor,
50
+ // constant across zero-downtime reloads, handed down via env) and the project root it serves. This is the
51
+ // answer the host gateway ([[host-gateway]]) compares an endpoint record against before proxying to it — a
52
+ // recycled port serving another project or a stale record fails the match instead of being routed to. Git-free
53
+ // after the first memoized resolution; a self-run child (no supervisor) answers instanceId:null, which no
54
+ // record claims, so it is simply not hosted.
55
+ const instanceStartedAt = new Date().toISOString()
56
+ app.get('/api/instance', (c) => {
57
+ const root = repoRoot()
58
+ return c.json({
59
+ instanceId: process.env.SPEXCODE_INSTANCE_ID ?? null,
60
+ root,
61
+ identity: resolveProjectIdentity(root, root),
62
+ pid: process.pid,
63
+ startedAt: instanceStartedAt,
64
+ })
65
+ })
42
66
  // the assembled graph (merged tree + overlay + sessions) — the dashboard's single source. Same data
43
67
  // as `spex graph --json`; the frontend only adds x/y pixels on top. Freshness is PUSH-first ([[graph-stream]]): the
44
68
  // dashboard reloads on a `/api/graph/stream` event, not a tight poll, so the route is a conditional-request
@@ -52,6 +76,7 @@ app.get('/health', (c) => c.text('ok'))
52
76
  // is bounded by [[graph-cache]]'s own build watchdog, so the next poll retries a fresh build.
53
77
  const BOARD_TIMEOUT_MS = Number(process.env.SPEXCODE_BOARD_TIMEOUT_MS || 20000)
54
78
  app.get('/api/graph', etag(), async (c) => {
79
+ ensureBoardFileWatchers()
55
80
  const timeout = Symbol('timeout')
56
81
  const json = await Promise.race([getBoardJson(), new Promise<typeof timeout>((r) => setTimeout(() => r(timeout), BOARD_TIMEOUT_MS))])
57
82
  if (json === timeout) return c.json({ error: 'graph build timed out' }, 503)
@@ -63,19 +88,9 @@ app.get('/api/graph/stream', (c) => boardStream(c))
63
88
  app.get('/api/specs', async (c) => c.json(await loadSpecs()))
64
89
  // the search corpus ([[graph-lean]]): a filesystem-only {id,title,path,desc,body} for every node, NO git. The
65
90
  // board omits `body` to stay lean, so the search palette fetches this ONCE when it opens (cached client-side)
66
- // to rank nodes over their prose — off the board's hot poll. A literal segment, before the `:id` routes.
67
- // Scenario prose rides the same corpus: the board's `scenarios` fold is slim ({name, tags}), so a measurable
68
- // node's row here carries its declared scenarios' description/expected (+ per-scenario code) — one fetch
69
- // serves both the palette's scenario plane and the focus-panel preview.
70
- app.get('/api/specs/lite', (c) => {
71
- const scByNode = new Map(evalNodes(repoRoot()).map((y) => [y.id, y.scenarios]))
72
- return c.json(loadSpecsLite().map((row) => {
73
- const sc = scByNode.get(row.id)
74
- return sc?.length
75
- ? { ...row, scenarios: sc.map((s) => ({ name: s.name, description: s.description, expected: s.expected, ...(s.code?.length ? { code: s.code } : {}) })) }
76
- : row
77
- }))
78
- })
91
+ // to rank nodes over their prose — off the board's hot poll. Review rows, including scenarios, come only
92
+ // from their paged endpoints and cannot be reconstructed from this corpus.
93
+ app.get('/api/specs/lite', (c) => c.json(loadSpecsLite()))
79
94
  // one node's body + parsed parts ([[graph-lean]]): the board no longer ships either, so the detail view
80
95
  // fetches this when a node opens. 404 for an unknown id.
81
96
  app.get('/api/specs/:id/content', (c) => {
@@ -131,9 +146,11 @@ app.post('/api/specs/:id/evals/ok', async (c) => {
131
146
  // else the bytes with a sniffed MIME and an immutable cache header (the name IS the content hash).
132
147
  // HTTP Range is honored — a <video> can only SEEK when the server answers byte ranges (a browser clamps
133
148
  // currentTime to the seekable window, which stays [0,0] without them); one general mechanism at the
134
- // transport, so every evidence kind streams the same way.
149
+ // transport, so every evidence kind streams the same way. A trailing `.<ext>` on the hash is IGNORED
150
+ // decoration for third-party markdown renderers (GitLab/GitHub only emit a <video> player when the URL
151
+ // ends in a video extension); the served bytes and MIME stay the stored ones — a wrong suffix never lies.
135
152
  app.get('/api/evidence/:hash', (c) => {
136
- const r = readBlobByHash(c.req.param('hash'))
153
+ const r = readBlobByHash(c.req.param('hash').replace(/\.[a-z0-9]+$/i, ''))
137
154
  if (!r.ok) return c.text(r.message, r.reason === 'invalid' ? 400 : 404)
138
155
  const total = r.bytes.length
139
156
  const base = { 'Content-Type': r.mime, 'Cache-Control': 'public, max-age=31536000, immutable', 'Accept-Ranges': 'bytes' }
@@ -158,30 +175,50 @@ app.post('/api/evidence', async (c) => {
158
175
  })
159
176
  // the SETTINGS read surface — one route for everything spexcode.json / spexcode.local.json resolves to:
160
177
  // `layout` (resolveLayout()'s main/worktrees/branch shape — the write-guard's project-identity probe reads
161
- // `.layout.main`) and the named launcher profiles ([[launcher-select]]) the New-Session form's dropdown
162
- // offers — `{ name, harness }` only (the `cmd` is a host secret, never shipped to the browser) — plus the
163
- // configured `default` NAME so the dropdown pre-selects the SAME launcher a bare `spex session new` uses
164
- // (the CLI/config default), instead of the alphabetically-first one. Missing defaultLauncher is returned
165
- // as an actionable config error, not hidden by falling through to the built-in `claude` launcher.
178
+ // `.layout.main`) and the named launcher profiles ([[launcher-select]]) the New-Session picker
179
+ // offers — `{ name, harness, cmd }`: the cmd is read-only display data for the picker (the dashboard sits
180
+ // behind the gateway auth; the browser can read but never edit config) plus the configured `default` NAME
181
+ // so the picker pre-selects the SAME
182
+ // launcher a bare `spex session new` uses (the CLI/config default), instead of the alphabetically-first one,
183
+ // Missing defaultLauncher is returned as an actionable config error, not hidden by falling through to the
184
+ // built-in `claude` launcher.
185
+ // `tmuxSocket` is the `-L <name>` label our private tmux server runs under (a backend fact, env-overridable),
186
+ // so the row's attach modal ([[attach-menu]]) can offer the RAW `tmux -L <socket> attach -t <id>` fallback
187
+ // beside the blessed `spex session attach` command — the frontend never hardcodes the socket.
166
188
  app.get('/api/settings', async (c) => c.json({
167
189
  layout: await resolveLayout(),
168
- launchers: launcherList().map(({ name, harness }) => ({ name, harness })),
190
+ launchers: launcherList(),
191
+ tmuxSocket: TMUX_SOCK,
169
192
  ...launcherDefault(),
170
193
  }))
171
- // the `surface: command` plugin-root nodes (built/active only) for the new-session `/` dropdown — each with
194
+ // the `surface: command` plugin-root nodes (built/active only) for new-session and live-inbox `/` dropdowns — each with
172
195
  // its prompt `body` ({{targets}} placeholder), `kind`, and folder `dir` + co-located `files`. surface is a
173
196
  // frontmatter field, not a dir (specs.ts loadSurface); `surface: system` siblings are gathered elsewhere.
174
- app.get('/api/plugins', (c) => c.json(loadConfig()))
197
+ // `?surface=review` lists the review-track presets instead ([[review-commands]] the eval detail's
198
+ // remark-composer `/` dropdown); the exposed surfaces stay this explicit whitelist, never a passthrough.
199
+ app.get('/api/plugins', (c) => c.json(c.req.query('surface') === 'review' ? loadReviewConfig() : loadConfig()))
175
200
  // the ISSUES read surface ([[issues]]) for the dashboard's issues page — the merged list over every store
176
201
  // (local threads + the resident forge slice), the SAME mergedIssues() the CLI drain reads, verbatim
177
202
  // (the dashboard computes nothing over it: no re-sort, no salience ranking). The `enabled` flag mirrors
178
203
  // the issues-workflow on/off switch so the frontend hides the view when the feature is OFF.
179
- app.get('/api/issues', etag(), (c) =>
180
- c.json({
181
- enabled: issuesEnabled(),
182
- stores: issueStores(),
183
- issues: mergedIssues({ host: resolveForgeHost(), state: residentForgeState() }, loadSpecsLite().map((s) => s.id)),
184
- }))
204
+ app.get('/api/issues', etag(), async (c) => c.json(await issuesReview(c.req.query('q'), c.req.query('page'))))
205
+ // Evals uses the identical paged-review response. `scope:` inside q selects the worktree source; without
206
+ // it the source is the current cached board. Filtering/counts always precede the one 25-row slice.
207
+ app.get('/api/evals', etag(), async (c) => {
208
+ ensureBoardFileWatchers()
209
+ const page = await evalsReview(c.req.query('q'), c.req.query('page'), { view: c.req.query('view') })
210
+ return page ? c.json(page) : c.json({ error: 'no such review source' }, 404)
211
+ })
212
+ // ONE bounded detail response for both source roots: the selected scenario's complete A/B history and at
213
+ // most five lightweight neighbors. It never serializes another scenario's history or the scoped model.
214
+ app.get('/api/evals/detail', etag(), async (c) => {
215
+ ensureBoardFileWatchers()
216
+ const node = c.req.query('node')?.trim()
217
+ const scenario = c.req.query('scenario')?.trim()
218
+ if (!node || !scenario) return c.json({ error: 'node and scenario are required' }, 400)
219
+ const detail = await evalDetailReview(node, scenario, c.req.query('scope')?.trim() || null)
220
+ return detail ? c.json(detail) : c.json({ error: 'no such review source' }, 404)
221
+ })
185
222
  // the single-thread read ([[issues]]) behind `spex issue show <id>` — the SAME findIssue lookup, from the
186
223
  // resident forge slice (instant view, background reconcile — the list route's freshness contract). A local
187
224
  // id, or a forge id (`<host>#<n>`); unknown → 404 (eval-remark threads are not issues, so they 404 here too).
@@ -323,7 +360,7 @@ app.post('/api/remarks/:action{resolve|retract}', async (c) => {
323
360
  return c.json({ error: String((e as Error).message || e) }, 400)
324
361
  }
325
362
  })
326
- // the dashboard input's `/` dropdown — computed by the launcher's HARNESS adapter the same way that harness
363
+ // the harness slice of the dashboard input's `/` dropdown — computed by the launcher's HARNESS adapter the same way that harness
327
364
  // computes its own `/` menu ([[harness-adapter]]). The client passes `?harness=<id>` for the ACTIVE session,
328
365
  // so a codex tab gets CODEX's menu, not the default's; unknown/absent → default. Insert-only on the client.
329
366
  app.get('/api/slash-commands', (c) => {
@@ -363,18 +400,9 @@ app.post('/api/sessions/edges/unwatch', async (c) => {
363
400
  return c.json({ ok }, ok ? 200 : 404)
364
401
  })
365
402
  app.post('/api/sessions', async (c) => {
366
- const body = await c.req.json().catch(() => ({}))
367
- const prompt = typeof body?.prompt === 'string' ? body.prompt : ''
368
- if (!prompt.trim()) return c.json({ error: 'empty prompt' }, 400)
369
- if (typeof body?.harness === 'string') return c.json({ error: 'harness is not a create-session input; use launcher' }, 400)
370
- // the named launcher ([[launcher-select]]) — fixes the session's harness AND its persisted launch command.
371
- const launcher = typeof body?.launcher === 'string' && body.launcher.trim() ? body.launcher.trim() : undefined
372
- // parent = the spawning session's id, resolved by the CALLER (createSession) in its own process and passed
373
- // through here ([[session-nesting]]); the browser's New Session omits it → a top-level session.
374
- const parent = typeof body?.parent === 'string' && body.parent.trim() ? body.parent.trim() : null
375
- try {
376
- return c.json(await newSession(typeof body?.node === 'string' ? body.node : null, prompt, parent, launcher), 201)
377
- } catch (e) { return c.json({ error: String((e as Error).message || e) }, 400) } // unknown launcher id → 400, not a 500
403
+ const body = await c.req.json().catch(() => null)
404
+ const result = await sessionCreateRequest(body)
405
+ return result.status === 201 ? c.json(result.session, 201) : c.json({ error: result.error }, 400)
378
406
  })
379
407
  // one server-side merge bundle (ahead/dirty/diff(merge-base)/gates/proposal) for the manager cockpit;
380
408
  // dashboard and `spex session review` are thin callers. 404 for an unknown id. See [[manager-cockpit]].
@@ -382,18 +410,14 @@ app.get('/api/sessions/:id/review', async (c) => {
382
410
  const r = await reviewPayload(c.req.param('id'))
383
411
  return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
384
412
  })
385
- // the ONE session eval read ([[session-eval]]): default = the lean interactive model — worktree-rooted
386
- // rows only, no diff enrichment, no inlined bytes (evidence streams lazily from /api/evidence), each
387
- // reading carrying `inSession` so the tab leads with what THIS session measured. `?format=html` = the
388
- // EXPORT artifact: one self-contained HTML (diff + gates + evidence inlined as data-URIs) for
389
- // CI/share/bare-browser — a heavier REPRESENTATION of the same read, not a second route. 404 unknown id.
413
+ // The self-contained HTML is the sole full-model transport exception. Interactive rows, including the CLI,
414
+ // use /api/evals pages; a bare request fails loudly rather than reopening a hidden full JSON path.
390
415
  app.get('/api/sessions/:id/evals', async (c) => {
391
416
  if (c.req.query('format') === 'html') {
392
417
  const m = await buildExportModel(c.req.param('id'))
393
418
  return m ? c.html(renderExportHtml(m)) : c.text('no such session', 404)
394
419
  }
395
- const m = await buildSessionEvals(c.req.param('id'))
396
- return m ? c.json(m) : c.json({ error: 'no such session' }, 404)
420
+ return c.json({ error: 'interactive eval rows use /api/evals pagination; use ?format=html only for export' }, 400)
397
421
  })
398
422
  // the session's live pane as text (one-shot snapshot) for a backend client (`spex session show --capture`). Empty and fail
399
423
  // stay distinct: an empty pane is 200 with empty body; unknown id → 404, offline (no live pane) → 409, error → 502.
@@ -404,6 +428,14 @@ app.get('/api/sessions/:id/capture', async (c) => {
404
428
  if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
405
429
  return c.text('capture failed', 502)
406
430
  })
431
+ // the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
432
+ // FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
433
+ // the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
434
+ app.get('/api/sessions/:id/timeline', (c) => {
435
+ const limit = Number(c.req.query('limit'))
436
+ const r = readTimeline(c.req.param('id'), Number.isFinite(limit) && limit > 0 ? limit : undefined)
437
+ return r ? c.json(r) : c.json({ error: 'no such session' }, 404)
438
+ })
407
439
  // the session RECORD detail (`spex session show`): the board row (status · node · branch · launcher · …)
408
440
  // plus the full originating prompt (the row itself carries only the preview). One id-addressed read backs
409
441
  // the CLI's show; 404 for an unknown id.
@@ -430,44 +462,88 @@ app.post('/api/sessions/:id/merge', async (c) => {
430
462
  return c.json(r, r.dispatched ? 200 : 409)
431
463
  })
432
464
 
433
- // one WS over a shared tmux control-mode client (pty-bridge): server→client = raw pane bytes (binary); the
434
- // view takes no keyboard input, so client→server is only a text control frame — {t:'resize',cols,rows} or
435
- // {t:'wheel',…}. The bridge resolves the wheel against tmux pane state: copy-mode repaint for normal panes,
436
- // SGR mouse report injection for mouse-owning TUIs. A real tmux client, so the first paint is one coherent
437
- // frame and live bytes arrive as events.
465
+ // one WS owns one native tmux client (pty-bridge): server→client = that client's rendered PTY bytes (binary);
466
+ // client→server text controls resize, visibility, and xterm-native input (which carries the mouse/wheel
467
+ // SGR reports xterm produces natively in mouse-report mode). Server→client text commits a completed resize immediately before its binary tmux transaction;
468
+ // hiding starts that viewer's bounded helper release without closing the warm socket. tmux itself resolves wheel input between
469
+ // copy-mode and a mouse-owning TUI. The bridge never splices capture-pane state into this stream.
470
+ // keep-alive ping cadence for the terminal socket — the server half of [[reconnect]]'s heartbeat contract,
471
+ // and the contract's ONE primitive number: the client mirrors it (SERVER_PING_MS in the dashboard's
472
+ // resilientSocket.js, pinned by its test) and DERIVES its silence deadline (2.5×) from it.
473
+ // A healthy link is guaranteed inbound traffic every PING window, so the client may presume an OPEN socket
474
+ // silent past its derived window dead. The same tick also sends a WebSocket protocol ping; browsers answer its
475
+ // pong below JavaScript, so a backend reload stays compatible with a tab running the previous frontend bundle.
476
+ // The server owns that mirror deadline and detaches the viewer itself when a half-open link never reports close.
477
+ // Terminal pixels remain binary frames, so heartbeat controls never enter xterm.
478
+ const TERM_PING_MS = 10000
479
+ const TERM_DEAD_MS = 2.5 * TERM_PING_MS
438
480
  app.get('/api/sessions/:id/socket', upgradeWebSocket((c) => {
439
481
  const id = c.req.param('id') as string
440
- // the size-first handshake: a client that already knows its pane size carries it as ?cols=&rows= so the
441
- // first frame is drawn at the true size. Absent/garbage → undefined, and the bridge falls back to prewarm.
442
- const qc = Number(c.req.query('cols')), qr = Number(c.req.query('rows'))
443
- const initialSize = qc > 0 && qr > 0 ? { cols: qc, rows: qr } : undefined
444
482
  let viewer: Viewer | null = null
483
+ let ping: ReturnType<typeof setInterval> | undefined
484
+ let pongDeadline: ReturnType<typeof setTimeout> | undefined
485
+ let cleaned = false
486
+ const disarmPongDeadline = () => { if (pongDeadline) clearTimeout(pongDeadline); pongDeadline = undefined }
487
+ let armPongDeadline = () => {}
488
+ const cleanup = () => {
489
+ if (cleaned) return
490
+ cleaned = true
491
+ if (ping) clearInterval(ping)
492
+ disarmPongDeadline()
493
+ if (viewer) detachViewer(id, viewer)
494
+ viewer = null
495
+ }
445
496
  return {
446
497
  onOpen(_evt, ws) {
447
- viewer = { send: (buf) => { try { ws.send(Uint8Array.from(buf)) } catch { /* viewer gone */ } } }
448
- if (!attachViewer(id, viewer, initialSize)) { try { ws.close() } catch { /* already closed */ } }
498
+ viewer = {
499
+ send: (buf) => { try { ws.send(Uint8Array.from(buf)) } catch { /* viewer gone */ } },
500
+ commitSize: (cols, rows) => { try { ws.send(JSON.stringify({ t: 'resize-commit', cols, rows })) } catch { /* viewer gone */ } },
501
+ }
502
+ attachViewer(id, viewer)
503
+ armPongDeadline = () => {
504
+ if (cleaned) return
505
+ disarmPongDeadline()
506
+ pongDeadline = setTimeout(() => {
507
+ cleanup()
508
+ try { ws.close() } catch { /* cleanup already detached the dead viewer */ }
509
+ }, TERM_DEAD_MS)
510
+ pongDeadline.unref()
511
+ }
512
+ // `raw` is @hono/node-ws's real ws.WebSocket. Protocol pong is intentionally the server-side liveness
513
+ // signal: unlike an application text reply, every browser generation answers it automatically.
514
+ ws.raw.on('pong', armPongDeadline)
515
+ armPongDeadline()
516
+ ping = setInterval(() => {
517
+ try { ws.raw.ping() } catch { /* viewer gone; onClose reaps */ }
518
+ try { ws.send('ping') } catch { /* client dead-man still needs observable inbound traffic */ }
519
+ }, TERM_PING_MS)
449
520
  },
450
521
  onMessage(evt) {
451
522
  if (!viewer) return
452
523
  const data = evt.data
453
- // no keyboard input: the only client→server messages are the resize frame and the wheel frame. Binary
454
- // is ignored; pane navigation stays inside the tmux bridge instead of becoming browser scroll state.
524
+ // Binary input is ignored; JSON keeps terminal input distinct from binary pane output while preserving
525
+ // xterm's ordered string exactly. The bridge accepts input only from this viewer's visible claim.
455
526
  if (typeof data === 'string') {
527
+ if (data === 'pong') {
528
+ armPongDeadline()
529
+ return
530
+ }
456
531
  try {
457
532
  const m = JSON.parse(data)
458
- if (m?.t === 'resize') resizeBridge(id, Number(m.cols), Number(m.rows), !!m.full)
459
- else if (m?.t === 'wheel') forwardWheel(id, !!m.up, Number(m.col), Number(m.row), Number(m.ticks))
533
+ if (m?.t === 'resize') resizeBridge(id, viewer, Number(m.cols), Number(m.rows))
534
+ else if (m?.t === 'visible' && m.visible === false) hideViewer(id, viewer)
535
+ else if (m?.t === 'input' && typeof m.data === 'string') forwardInput(id, viewer, m.data)
460
536
  } catch { /* ignore */ }
461
537
  }
462
538
  },
463
- onClose() { if (viewer) detachViewer(id, viewer) },
539
+ onClose() { cleanup() },
464
540
  }
465
541
  }))
466
542
  // ONE input route, `kind` the discriminator — the transport split is an implementation fact, not API surface.
467
- // kind:"text" (the docked ❯ line, `spex session send`, the server-side merge dispatch) injects a whole prompt
543
+ // kind:"text" (Command Box, `spex session send`, the server-side merge dispatch) injects a whole prompt
468
544
  // through the rendezvous control socket — socket-only + fail-loud: a prompt the agent doesn't confirm
469
545
  // accepting returns 502 with the reason (never a silent 200), so a dead dispatch is seen, not a false success.
470
- // kind:"keys" is the LAST-RESORT raw face (`send --keys`, the dashboard's type mode): an ORDERED BATCH of
546
+ // kind:"keys" is the LAST-RESORT raw face (`spex session send --keys`): an ORDERED BATCH of
471
547
  // nav-mode key tokens over tmux send-keys, delivered in array order so tap order survives
472
548
  // ([[nav-mode-key-ordering]]); unstable by nature — callers try a plain text send first. An unknown kind is a
473
549
  // loud 400, never a guessed channel.
@@ -475,8 +551,10 @@ app.post('/api/sessions/:id/input', async (c) => {
475
551
  const body = await c.req.json().catch(() => ({}))
476
552
  if (body?.kind === 'text') {
477
553
  // `from` (the sender's session id) rides only an agent-to-agent send → the backend records the comms
478
- // edge ([[comms-edge]]); a raw human dispatch omits it and is not logged.
479
- const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined)
554
+ // edge ([[comms-edge]]); a raw human dispatch omits it and is not logged. `replyVia:"note"` marks a
555
+ // terminal-free sender ([[session-timeline]]): the server appends the note-reply insert to the delivery.
556
+ const r = await sendText(c.req.param('id'), typeof body?.text === 'string' ? body.text : '', typeof body?.from === 'string' ? body.from : undefined,
557
+ body?.replyVia === 'note' ? { replyVia: 'note' } : {})
480
558
  return c.json(r, r.ok ? 200 : 502)
481
559
  }
482
560
  if (body?.kind === 'keys') {
@@ -513,24 +591,23 @@ app.post('/api/sessions/:id/sort', async (c) => {
513
591
  const port = Number(process.env.PORT || 8787)
514
592
  // @@@ server-side connection reaping ([[spec-cli]]) - abandoned connections must die SERVER-SIDE, or they
515
593
  // pile up and wedge the backend (135 leaked conns once starved :8787 into looking dead — the cascade that
516
- // triggered the mass-restore incident, since every client-side timeout-kill leaks one). The `serverOptions`
517
- // timeouts below are kept (harmless), but they are NOT the mechanism: MEASURED (eval
518
- // server-reaps-abandoned-connections), Node's `headersTimeout`/`requestTimeout` do NOT reap an INCOMPLETE
519
- // request via the connectionsCheckingInterval sweep a slow-loris survives indefinitely; only
520
- // keepAliveTimeout (idle-between-requests) ever fires. So the real reaper is the explicit socket-level
521
- // `installConnectionReaper` below (see reaper.ts): a per-socket deadline that reaps a slow-loris / idle
522
- // keep-alive but exempts an ACTIVE WS/SSE stream (board-stream, terminal socket) for as long as it streams.
594
+ // triggered the mass-restore incident, since every client-side timeout-kill leaks one). The ONE mechanism is
595
+ // the socket-level `installConnectionReaper` below (reaper.ts): a per-socket deadline that reaps a
596
+ // slow-loris / idle keep-alive but exempts an ACTIVE WS/SSE stream (board-stream, terminal socket) for as
597
+ // long as it streams. Deliberately NO `serverOptions` timeouts here: they were measured to be not harmless
598
+ // but a second mechanism racing the reaper (issue #65 a 20s headersTimeout won at default config and
599
+ // silently capped SPEXCODE_REAP_HEADER_MS); the install disables Node's overlapping timeouts so the
600
+ // deadlines have a single owner.
523
601
  // @@@ loopback bind ([[public-mode]]) - this child is NEVER the internet face: the supervisor (and in public
524
602
  // mode the gateway) fronts it, and dials it only via 127.0.0.1. Binding loopback is what makes "loopback is
525
603
  // the trust boundary" true — without a hostname Node binds all interfaces and the child is reachable from
526
604
  // the LAN with no password, bypassing the gate entirely (measured: eval auth-boundary).
527
- const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1', serverOptions: {
528
- keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000,
529
- } })
605
+ const server = serve({ fetch: app.fetch, port, hostname: '127.0.0.1' })
530
606
  installConnectionReaper(server as unknown as HttpServer)
531
607
  injectWebSocket(server)
532
- superviseBridges() // keep a warm tmux client per live session, so opening a tab is instant
608
+ superviseBridges() // restore visible helpers after failure; their viewer subscriptions survive replacement
533
609
  superviseQueue() // launch queued sessions as slots free (catches agent-authored proposals/crashes the server never sees directly)
610
+ superviseTimeline() // record authored-lifecycle transitions to each session's durable timeline ([[session-timeline]])
534
611
  console.log(`spec-cli serving .spec (from git) on http://localhost:${port}`)
535
612
 
536
613
  // graceful drain (the other half of zero-downtime reload, supervise.ts): on SIGTERM stop accepting new