spexcode 0.6.1 → 0.6.2

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 (72) hide show
  1. package/README.md +97 -149
  2. package/package.json +1 -1
  3. package/spec-cli/hooks/harness.sh +26 -6
  4. package/spec-cli/src/anchors.ts +27 -5
  5. package/spec-cli/src/cli.ts +171 -28
  6. package/spec-cli/src/client.ts +112 -13
  7. package/spec-cli/src/delivery-queue.ts +62 -4
  8. package/spec-cli/src/execution-trace.ts +444 -0
  9. package/spec-cli/src/gateway-hub.ts +10 -8
  10. package/spec-cli/src/gateway.ts +99 -6
  11. package/spec-cli/src/git.ts +477 -101
  12. package/spec-cli/src/graph.ts +56 -6
  13. package/spec-cli/src/graphCache.ts +86 -17
  14. package/spec-cli/src/graphStream.ts +1 -1
  15. package/spec-cli/src/guide.ts +93 -4
  16. package/spec-cli/src/harness-select.ts +2 -2
  17. package/spec-cli/src/harness.ts +344 -66
  18. package/spec-cli/src/help.ts +22 -10
  19. package/spec-cli/src/host-resources.ts +62 -7
  20. package/spec-cli/src/identity-presets.js +16 -6
  21. package/spec-cli/src/index.ts +101 -13
  22. package/spec-cli/src/layout.ts +116 -33
  23. package/spec-cli/src/lint.ts +37 -7
  24. package/spec-cli/src/materialize.ts +10 -5
  25. package/spec-cli/src/plugin-harness.ts +10 -10
  26. package/spec-cli/src/reviews.ts +57 -28
  27. package/spec-cli/src/session-execution.ts +68 -0
  28. package/spec-cli/src/session-files.ts +121 -0
  29. package/spec-cli/src/session-follow.ts +4 -4
  30. package/spec-cli/src/session-reparent.ts +33 -0
  31. package/spec-cli/src/session-timeline.ts +163 -14
  32. package/spec-cli/src/session-web.ts +135 -0
  33. package/spec-cli/src/sessions.ts +584 -104
  34. package/spec-cli/src/uninstall.ts +1 -1
  35. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +2 -21
  36. package/spec-cli/templates/spec/project/.plugins/core/spec.md +12 -13
  37. package/spec-cli/templates/spec/project/.plugins/prompts/atomic-landing/spec.md +5 -22
  38. package/spec-cli/templates/spec/project/.plugins/prompts/forge-link/spec.md +2 -1
  39. package/spec-cli/templates/spec/project/.plugins/prompts/memory-hygiene/spec.md +4 -6
  40. package/spec-cli/templates/spec/project/.plugins/prompts/reproduce-before-fix/spec.md +3 -9
  41. package/spec-dashboard/dist/assets/{App-F9uaAVcH.js → App-B6IbFbGu.js} +2 -2
  42. package/spec-dashboard/dist/assets/Dashboard-_sDk7Rcg.js +27 -0
  43. package/spec-dashboard/dist/assets/EvalsPage-BT357Lyt.js +2 -0
  44. package/spec-dashboard/dist/assets/{IssuesPage-CuKLFhH3.js → IssuesPage-BG9rNOgV.js} +1 -1
  45. package/spec-dashboard/dist/assets/MobileApp-D9yPMR3A.js +2 -0
  46. package/spec-dashboard/dist/assets/{Modal-CQgYymmr.js → Modal-B3EcgeA3.js} +1 -1
  47. package/spec-dashboard/dist/assets/{PageScroll-hT7UTLvD.js → PageScroll-CNZOugWV.js} +1 -1
  48. package/spec-dashboard/dist/assets/{ProjectsPage-CtXxakF9.js → ProjectsPage-C2CRHmvs.js} +1 -1
  49. package/spec-dashboard/dist/assets/SessionInterface-DOEjuT3l.js +40 -0
  50. package/spec-dashboard/dist/assets/{SessionWindow-CixDi4PI.js → SessionWindow-1nYgZ4CL.js} +1 -1
  51. package/spec-dashboard/dist/assets/Settings-7WeTJn0V.js +1 -0
  52. package/spec-dashboard/dist/assets/Thread-CEbzAFQn.js +13 -0
  53. package/spec-dashboard/dist/assets/data-SlvxNA2r.js +1 -0
  54. package/spec-dashboard/dist/assets/{index-DrVao0Ep.js → index-DLVeOHL-.js} +10 -10
  55. package/spec-dashboard/dist/assets/index-DYLGfPjx.css +1 -0
  56. package/spec-dashboard/dist/assets/{launch-BBH02b1v.js → launch-yxCYr64x.js} +29 -29
  57. package/spec-dashboard/dist/assets/sessionSurface--7l69h4F.js +1 -0
  58. package/spec-dashboard/dist/index.html +2 -2
  59. package/spec-eval/src/cli.ts +6 -1
  60. package/spec-eval/src/evaltab.ts +7 -5
  61. package/spec-eval/src/freshness.ts +383 -104
  62. package/spec-eval/src/scenariofresh.ts +2 -2
  63. package/spec-eval/src/sessioneval.ts +126 -132
  64. package/spec-dashboard/dist/assets/Dashboard-Ba_jhxp1.js +0 -27
  65. package/spec-dashboard/dist/assets/EvalsPage-FixoOg_n.js +0 -2
  66. package/spec-dashboard/dist/assets/MobileApp-CHgEHORJ.js +0 -2
  67. package/spec-dashboard/dist/assets/SessionInterface-Bpie-9fs.js +0 -39
  68. package/spec-dashboard/dist/assets/Settings-C2MsucfE.js +0 -1
  69. package/spec-dashboard/dist/assets/Thread-C6Go8HRh.js +0 -13
  70. package/spec-dashboard/dist/assets/data-B-RQmit6.js +0 -1
  71. package/spec-dashboard/dist/assets/index-CsI8DElI.css +0 -1
  72. package/spec-dashboard/dist/assets/terminalFont-C6oGNKrf.js +0 -1
@@ -19,7 +19,8 @@ none (or @all) means every session.`
19
19
 
20
20
  const ROUTING_NOTE = `Backend routing: every backend-touching verb accepts --api <url> (--port <n> = localhost sugar) to name
21
21
  its backend explicitly — the flag always wins. Bare, it resolves: worker env / the cwd project's live
22
- recorded backend / fallback / :8787 (spex guide settings → BACKEND ROUTING).`
22
+ recorded backend / fallback / :8787 (spex guide settings → BACKEND ROUTING). A password-gated explicit
23
+ gateway accepts --password <pw> (or SPEXCODE_PASSWORD); its self-signed certificate needs explicit --insecure.`
23
24
 
24
25
  const DOT_NOTE = `\`.\` as a node argument means the node THIS worktree works on (the session's bound node, else the
25
26
  node/<id> branch). One-shot payload reads (graph · spec search · session ls/show/review · eval ls ·
@@ -36,14 +37,21 @@ type SessionVerbHelp = readonly [usage: string | readonly string[], detail: stri
36
37
  // Keeping the behavioral prose here prevents a compact probe from becoming a second, drifting manual.
37
38
  function sessionHelpDefinitions(): Record<string, SessionVerbHelp> {
38
39
  return {
39
- new: ['spex session new "<prompt>" [--prompt-file <path>|-] [--launcher <name>]',
40
+ new: ['spex session new "<prompt>" [--prompt-file <path>|-] [--launcher <name>] [--name <name>] [--base <commit-ish>]',
40
41
  `Launch a worker in its own node worktree. The materialized system contract reaches it
41
42
  automatically; the prompt supplies the task context. Its first [[id]] mention binds the
42
43
  session to that node. --prompt-file <path>|- carries a long prompt without shell quoting
43
- (exclusive with the inline prompt). The successful receipt names what to read, monitor, and reply on.`],
44
+ (exclusive with the inline prompt). --name sets the session's initial display name without changing the prompt.
45
+ --base <commit-ish> pins the fork point instead of the source-of-truth branch's current head, so a run can be
46
+ reproduced against a frozen commit; a base that names no commit is refused before anything is created.
47
+ The successful receipt names what to read, monitor, and reply on.`],
44
48
  ls: ['spex session ls [SEL…] [--status a,b] [--all] [--json]',
45
49
  'One-shot table of living sessions. Shelved sessions ([[archive]]) are hidden; --all includes them, and naming one explicitly always shows it.', ['selector']],
46
50
  resources: ['spex session resources [--json]', 'Read-only host/process ownership, budgets, shared refs, and findings.'],
51
+ files: [['spex session files add <path>', 'spex session files ls', 'spex session files retract <path>'],
52
+ 'Publish, list, or withdraw YOUR session’s live file paths. Posting stores an absolute path beside the session record without copying bytes; the dashboard downloads it only when the human clicks.'],
53
+ web: [['spex session web add <url>', 'spex session web ls', 'spex session web retract <url>'],
54
+ 'Publish, list, or withdraw YOUR session’s live loopback web-service URLs. Posting records no bytes and does not contact the service; the dashboard proxies the current HTTP/WebSocket service only when the human opens its preview.'],
47
55
  watch: [['spex session watch <SEL…>', 'spex session watch list', 'spex session watch cancel <SEL…>', 'spex session watch stream [SEL…] [--as NAME] [--idle] [--interval N=1]'],
48
56
  'With a governed caller, watch registers durable send-backed state delivery and exits. list/cancel manage those relations. Without a governed caller it names the background `session wait` fallback. stream is the human-only continuous log view and blocks until killed.', ['selector']],
49
57
  wait: ['spex session wait [SEL…] [--timeout S=1200] [--interval S=1] [--idle]',
@@ -58,8 +66,10 @@ It reads files only, so it needs no \`spex serve\` and costs the sessions it fol
58
66
  session's store is gone.`, ['selector']],
59
67
  review: ['spex session review <SEL> [--json]', 'Reports ahead · uncommitted · proposal · gates · merge-base diff.', ['selector']],
60
68
  merge: ['spex session merge <SEL>', 'Dispatches a gated merge to the session\'s own agent; it does not close the session.', ['selector', 'project-bound']],
61
- send: [['spex session send <SEL> "<msg>"', 'spex session send <SEL> --keys "<keys>"'],
62
- `Plain send delivers a message once its timeline append succeeds; a dead adapter only delays its context. --keys is the LAST RESORT:
69
+ reparent: ['spex session reparent <child-SEL...> --to <parent-SEL>',
70
+ 'Move one or more governed children to a replacement parent, replacing only the former parent\'s managed watch relation. It never restarts a child and works when the former parent is offline.', ['selector', 'project-bound']],
71
+ send: [['spex session send <SEL> "<msg>"', 'spex session send <SEL> [--api <url> | --port <n>] -- <option-shaped-msg>', 'spex session send <SEL> --keys "<keys>"'],
72
+ `Plain send delivers a message once its timeline append succeeds; a dead adapter only delays its context. Routing flags may precede or follow ordinary text; use -- before a message that begins with --. --keys is the LAST RESORT:
63
73
  raw nav-mode keystrokes to a TUI dialog ("Up Up Enter", C-/M-/S- combos). The raw key surface
64
74
  is UNSTABLE and can confirm dangerous dialogs — try a plain send first; use keys only when text
65
75
  provably cannot land.`, ['selector', 'project-bound']],
@@ -88,13 +98,13 @@ LOCAL-only (fails loud on a remote backend); show --capture and send are non-int
88
98
  }
89
99
 
90
100
  const SESSION_HELP_GROUPS = [
91
- { title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge'] },
101
+ { title: 'Manager verbs (dispatch, monitor, land)', verbs: ['new', 'ls', 'resources', 'watch', 'wait', 'review', 'merge', 'reparent'] },
92
102
  { title: 'Control another session', verbs: ['send', 'interrupt', 'rename', 'show', 'resume', 'stop', 'archive', 'unarchive', 'close', 'quarantine'] },
93
- { title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask'] },
103
+ { title: 'Worker verbs (declare YOUR OWN state — a claim the graph and your supervisor act on)', verbs: ['done', 'park', 'ask', 'files', 'web'] },
94
104
  { title: 'Human escape hatch', verbs: ['attach'] },
95
105
  ] as const
96
106
 
97
- const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge) are PROJECT-BOUND: a backend serving
107
+ const SESSION_WRITE_NOTE = `Manager verbs that WRITE (send/interrupt/rename/resume/stop/close/merge/reparent) are PROJECT-BOUND: a backend serving
98
108
  another project's repo refuses loudly — name the target with --api <url> to drive it on purpose.`
99
109
 
100
110
  function indent(text: string, spaces: number): string {
@@ -235,7 +245,7 @@ an admin password exists, and ungated projects serve open.`,
235
245
  line: 'spec <verb> the governance graph: search · owner · lint · ack',
236
246
  body: `Usage: spex spec search <query…> [--limit N=10] [--json]
237
247
  spex spec owner <path> [--actionable]
238
- spex spec lint
248
+ spex spec lint [--json]
239
249
  spex spec ack <node-id>… --reason "<why the contract still holds>"
240
250
 
241
251
  search — which spec node GOVERNS a topic, ranked by user-story relevance (which surfaces user-facing
@@ -259,7 +269,9 @@ leaf id reused) · mention (a [[id]] naming no node). Warns: coverage · drift
259
269
  "ignore"\` may silence it) · anchor (anchoring a type) · related-drift (a scoped related row warns
260
270
  per selector HIT, misses silent) · owners (whole-file governors only; scoped don't count) ·
261
271
  confusable-id (two leaf ids one edit apart). spec lint's errors BLOCK commits (the prepared reference hook; bypass SPEXCODE_SKIP_LINT=1);
262
- contrast \`spex eval lint\`, which is pure advisory and never blocks anyone.
272
+ contrast \`spex eval lint\`, which is pure advisory and never blocks anyone. \`--json\` writes the
273
+ versioned machine report to stdout (source candidates + structured findings); its exit code still blocks
274
+ on errors.
263
275
 
264
276
  ack — stamp Spec-OK on HEAD (an empty stamp commit): the drift remedy when only MECHANICS changed
265
277
  and the spec's contract still holds. --reason is required and recorded in the ack commit's body
@@ -3,7 +3,7 @@ import { execFileSync } from 'node:child_process'
3
3
  import { randomUUID } from 'node:crypto'
4
4
  import { mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
5
5
  import { dirname, join } from 'node:path'
6
- import { defaultHarness, HARNESSES, harnessById, sessionIdentityEnvVars, type HarnessLivenessRecord, type SharedRuntimeDescriptor, type SharedRuntimeProbe } from './harness.js'
6
+ import { defaultHarness, HARNESSES, harnessById, harnessByIdOrNull, sessionIdentityEnvVars, type HarnessLivenessRecord, type SharedRuntimeDescriptor, type SharedRuntimeProbe } from './harness.js'
7
7
  import { listSessionIds, readConfig, readJsonConfig, readPublicRecordEntry, readRawRecord, runtimeRoot, type PublicRecordEntry, type RawRecord } from './layout.js'
8
8
  import { repoRoot } from './git.js'
9
9
  import { endpointRecordPath } from './host.js'
@@ -219,6 +219,31 @@ const runtimePid = (file: string): number | null => {
219
219
  catch { return null }
220
220
  }
221
221
  type SharedEntry = { descriptor: SharedRuntimeDescriptor; recs: RawRecord[] }
222
+ // A session record outlives the config that created it, so a removed plugin harness or a renamed harness id
223
+ // leaves a governed record this registry cannot resolve. That is a normal long-lived state for a host-wide
224
+ // sweep, not a request naming a bad id: the sweep reports the record and keeps every other owner's findings.
225
+ const unresolvableHarness = (rec: RawRecord): string | null => {
226
+ const id = rec.harness || defaultHarness.id
227
+ return harnessByIdOrNull(id) ? null : id
228
+ }
229
+ // Mirrors the corrupt-record owner: a record the sweep could not fully read stays a public owner so its
230
+ // absence from a process-derived group cannot read as health. Here the unknown half is the harness, so
231
+ // lifecycle is unknown and reclaim is closed rather than guessed from the record's own status field.
232
+ const unresolvedHarnessOwners = (recs: RawRecord[], budgets: ResourceBudgets, have: (sessionId: string) => boolean): ResourceOwner[] => {
233
+ const out: ResourceOwner[] = []
234
+ for (const rec of recs) {
235
+ const unresolved = unresolvableHarness(rec)
236
+ if (!unresolved || !rec.governed || rec.stopped || have(rec.session_id)) continue
237
+ out.push({
238
+ kind: 'session', id: rec.session_id, label: `session ${rec.session_id.slice(0, 8)}`, status: rec.status, liveness: 'unknown',
239
+ processes: [], rssMiB: 0, pssMiB: null, cpuPercent: 0,
240
+ budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
241
+ findings: [`harness-unresolved:${unresolved}`],
242
+ reclaim: { eligible: false, reason: `harness '${unresolved}' is not resolvable by this registry; shared-runtime stop safety cannot be proven` },
243
+ })
244
+ }
245
+ return out
246
+ }
222
247
  const sharedDescriptors = (recs: RawRecord[], retainRegistry = false): Map<string, SharedEntry> => {
223
248
  const out = new Map<string, { descriptor: SharedRuntimeDescriptor; recs: RawRecord[] }>()
224
249
  for (const harness of HARNESSES) for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
@@ -229,7 +254,8 @@ const sharedDescriptors = (recs: RawRecord[], retainRegistry = false): Map<strin
229
254
  // reported as an archive hazard. Clean archived records carry stopped:true and no loaded reference, so they
230
255
  // contribute nothing to the active set while still preserving exact ownership if the invariant is violated.
231
256
  if (!rec.governed) continue
232
- const harness = harnessById(rec.harness || defaultHarness.id)
257
+ const harness = harnessByIdOrNull(rec.harness || defaultHarness.id)
258
+ if (!harness) continue // joined to no descriptor; surfaced as a finding on that record's own session owner
233
259
  const exactKey = harness.targetDescriptorKey?.({ session: rec.session_id, harnessSessionId: rec.harness_session_id }) ?? null
234
260
  for (const descriptor of harness.sharedRuntimes?.(runtimeRoot()) ?? []) {
235
261
  if (exactKey && descriptor.key !== exactKey) continue
@@ -381,6 +407,22 @@ const probeRuntime = async (descriptor: SharedRuntimeDescriptor): Promise<Shared
381
407
  catch (error) { return { healthy: false, references: [], error: (error as Error).message } }
382
408
  }
383
409
 
410
+ const sessionOwnerBlocker = (id: string, harnessId: string | null, recs: RawRecord[]): string | null => {
411
+ const targetRecord = recs.find((rec) => rec.session_id === id)
412
+ const targetHarness = harnessId ? harnessById(harnessId) : null
413
+ const targetThread = targetRecord?.harness_session_id
414
+ if (!targetHarness || !targetThread) return null
415
+ const ownerCount = recs.filter((rec) => rec.governed && (rec.harness || defaultHarness.id) === targetHarness.id && rec.harness_session_id === targetThread).length
416
+ if (ownerCount === 1) return null
417
+ const label = targetHarness.sharedRuntimes?.(runtimeRoot())[0]?.label ?? targetHarness.id
418
+ return `${label} target thread ${targetThread} has no one exact governed session owner`
419
+ }
420
+
421
+ export function assertSessionOwnerSafe(id: string, harnessId: string): void {
422
+ const blocker = sessionOwnerBlocker(id, harnessId, rawRecords())
423
+ if (blocker) throw new ResourceConflict(blocker)
424
+ }
425
+
384
426
  const sessionStopBlocker = async (
385
427
  id: string,
386
428
  harnessId: string | null,
@@ -390,6 +432,9 @@ const sessionStopBlocker = async (
390
432
  ): Promise<string | null> => {
391
433
  const targetRecord = recs.find((rec) => rec.session_id === id)
392
434
  const targetHarness = harnessId ? harnessById(harnessId) : null
435
+ const targetThread = targetRecord?.harness_session_id
436
+ const ownerBlocker = sessionOwnerBlocker(id, harnessId, recs)
437
+ if (ownerBlocker) return ownerBlocker
393
438
  const exactKey = targetHarness?.targetDescriptorKey?.({ session: id, harnessSessionId: targetRecord?.harness_session_id }) ?? null
394
439
  const targetDescriptors = targetHarness?.sharedRuntimes?.(runtimeRoot()) ?? []
395
440
  if (targetHarness && targetDescriptors.length === 0) return null
@@ -404,9 +449,9 @@ const sessionStopBlocker = async (
404
449
  const pid = runtimePid(descriptor.pidFile)
405
450
  const ownerCounts = new Map<string, number>()
406
451
  for (const rec of entry.recs) if (rec.harness_session_id) ownerCounts.set(rec.harness_session_id, (ownerCounts.get(rec.harness_session_id) ?? 0) + 1)
407
- const targetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
408
- if (targetThread && ownerCounts.get(targetThread) !== 1)
409
- return `${descriptor.label} target thread ${targetThread} has no one exact governed session owner`
452
+ const entryTargetThread = entry.recs.find((rec) => rec.session_id === id)?.harness_session_id
453
+ if (entryTargetThread && ownerCounts.get(entryTargetThread) !== 1)
454
+ return `${descriptor.label} target thread ${entryTargetThread} has no one exact governed session owner`
410
455
  if (!knownProbes && descriptor.mutationGuard) {
411
456
  if (!targetThread) return `${descriptor.label} target has no exact governed thread identity`
412
457
  if (!pid) return `${descriptor.label} target-scoped mutation guard has no readable owner PID`
@@ -506,6 +551,7 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
506
551
  budget: { rssMiB: budgets.sessionRssMiB, idleCpuPercent: budgets.idleCpuPercent },
507
552
  findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
508
553
  })
554
+ owners.push(...unresolvedHarnessOwners(recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)))
509
555
  const report: ResourceReport = { version: 1, measuredAt: new Date().toISOString(), projectRoot: root, platform: platform(), available: false, unavailableReason: `host process metrics are unavailable on ${platform()}; shared runtime references remain visible`, host: { memoryTotalMiB: null, memoryUsedMiB: null, memoryAvailableMiB: null, swapTotalMiB: null, swapUsedMiB: null, cpuPercent: null }, budgets, owners, totals: { rssMiB: 0, pssMiB: null, cpuPercent: 0 }, findings: owners.reduce((n, o) => n + o.findings.length, 0) }
510
556
  if (opts.persist !== false) atomicJson(join(runtimeRoot(), 'resource-report.json'), report)
511
557
  return report
@@ -562,8 +608,15 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
562
608
  if (rec?.archived) findings.push('archived-runtime-hazard:leaf-still-resident')
563
609
  if (totals.rssMiB > rssBudget) findings.push(`rss-over-budget:${Math.round((totals.rssMiB - rssBudget) * 10) / 10}MiB`)
564
610
  if (status !== 'active' && status !== 'queued' && totals.cpuPercent > idleBudget) findings.push(`idle-cpu-over-budget:${Math.round((totals.cpuPercent - idleBudget) * 10) / 10}%`)
565
- const stopBlocker = rec ? await sessionStopBlocker(id, rec.harness || defaultHarness.id, inv.recs, sharedProbes) : null
566
- if (terminal(rec) && totals.cpuPercent <= idleBudget && !stopBlocker) {
611
+ // The stop guard resolves the record's harness fail-loud, which is right for a stop REQUEST and wrong
612
+ // here: this sweep reads rows nobody named. An unresolvable harness makes stop safety unprovable, so it
613
+ // is reported and reclaim stays closed — never a resolvable-looking zero, never a lost report.
614
+ const unresolved = rec ? unresolvableHarness(rec) : null
615
+ if (unresolved) findings.push(`harness-unresolved:${unresolved}`)
616
+ const stopBlocker = rec && !unresolved ? await sessionStopBlocker(id, rec.harness || defaultHarness.id, inv.recs, sharedProbes) : null
617
+ if (unresolved) {
618
+ reclaim = { eligible: false, reason: `harness '${unresolved}' is not resolvable by this registry; shared-runtime stop safety cannot be proven` }
619
+ } else if (terminal(rec) && totals.cpuPercent <= idleBudget && !stopBlocker) {
567
620
  reclaim = { eligible: true, reason: `terminal lifecycle ${rec!.status}/${rec!.proposal}; a future exact action must revalidate every fact` }
568
621
  } else reclaim = { eligible: false, reason: stopBlocker ? `shared runtime unsafe: ${stopBlocker}` : terminal(rec) ? 'terminal record is still consuming CPU; liveness contradicts safe retirement' : 'owner is not terminal; budget age/status alone never authorizes stop' }
569
622
  } else if (owner.startsWith('shared:')) {
@@ -621,6 +674,8 @@ export async function collectResourceReport(opts: { procRoot?: string; persist?:
621
674
  findings: [`session-record-corrupt:${entry.error}`], reclaim: { eligible: false, reason: 'session record is corrupt; ownership and lifecycle are unknown' },
622
675
  })
623
676
 
677
+ owners.push(...unresolvedHarnessOwners(inv.recs, budgets, (sessionId) => owners.some((owner) => owner.kind === 'session' && owner.id === sessionId)))
678
+
624
679
  // A referenced shared runtime with no readable process is still operationally important: keep its live or
625
680
  // unknown refcount visible instead of silently omitting it from a process-derived report.
626
681
  for (const [key, shared] of inv.shared) if (!owners.some((o) => o.kind === 'shared-runtime' && o.id === key)) {
@@ -1,18 +1,28 @@
1
1
  // One browser-safe identity registry shared by backend validation and every dashboard projection.
2
2
  // Geometry is data so the React renderer and favicon serializer cannot drift into separate drawings.
3
+ //
4
+ // @@@ shape keys travel through two renderers - a shape's own attributes override the group's
5
+ // fill/stroke, which is how a multi-colour mark fits a single-fg registry. Both consumers pass the keys
6
+ // through verbatim: the string serializer spreads them as attributes, React spreads them as JSX props.
7
+ // So a key must be valid in BOTH, which rules out hyphenated names (React wants strokeWidth) - hence a
8
+ // painted seam outline rather than a stroked line, since per-shape stroke-width cannot be expressed.
3
9
 
4
10
  export const DEFAULT_PROJECT_ICON = 'spexcode'
5
11
  export const DEFAULT_GATEWAY_ICON = 'gateway'
6
12
 
7
13
  export const IDENTITY_PRESETS = Object.freeze([
8
14
  {
9
- id: 'spexcode', label: 'SpexCode', bg: '#166534', fg: '#f0fdf4',
15
+ // The brand mark: two brackets holding one file, cleft corner to corner and regenerated on the
16
+ // upper-right of the cleft. Painted rather than stroked — see the paint note below. Geometry is
17
+ // docs/brand/mark-dark.svg scaled by 24/512; the two surface gradients flatten to their midpoints
18
+ // because a 24-unit chip has no room for a ramp and the format carries no defs.
19
+ id: 'spexcode', label: 'SpexCode', bg: '#12161C', fg: '#EFE8D8',
10
20
  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' },
21
+ { tag: 'path', d: 'M 4.500,4.500 L 9.750,4.500 L 9.750,6.656 L 6.656,6.656 L 6.656,17.344 L 9.750,17.344 L 9.750,19.500 L 4.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
22
+ { tag: 'path', d: 'M 19.500,4.500 L 14.250,4.500 L 14.250,6.656 L 17.344,6.656 L 17.344,17.344 L 14.250,17.344 L 14.250,19.500 L 19.500,19.500 Z', fill: '#EFE8D8', stroke: 'none' },
23
+ { tag: 'path', d: 'M 15.281,15.281 L 11.731,13.057 L 12.269,10.943 L 8.719,8.719 L 8.719,15.281 Z', fill: '#327D95', stroke: 'none' },
24
+ { tag: 'path', d: 'M 8.719,8.719 L 12.269,10.943 L 11.731,13.057 L 15.281,15.281 L 15.281,12.000 L 12.000,8.719 Z', fill: '#82C3D6', stroke: 'none' },
25
+ { tag: 'path', d: 'M 8.569,8.957 L 11.946,11.073 L 11.408,13.186 L 15.132,15.520 L 15.431,15.043 L 12.054,12.927 L 12.592,10.814 L 8.868,8.480 Z', fill: '#E4F7FD', stroke: 'none' },
16
26
  ],
17
27
  },
18
28
  {
@@ -1,6 +1,8 @@
1
1
  import { serve } from '@hono/node-server'
2
2
  import type { Server as HttpServer, ServerResponse as HttpServerResponse } from 'node:http'
3
3
  import { randomUUID } from 'node:crypto'
4
+ import { createReadStream } from 'node:fs'
5
+ import { Readable } from 'node:stream'
4
6
  import { installConnectionReaper } from './reaper.js'
5
7
  import { Hono } from 'hono'
6
8
  import { cors } from 'hono/cors'
@@ -20,18 +22,21 @@ import { gitA, gitTry, repoRoot } from './git.js'
20
22
  import { cockpitReview } from './cockpit.js'
21
23
  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
24
  import { readTimeline } from './session-timeline.js'
25
+ import { readSessionExecution, sessionExecutionStream } from './session-execution.js'
23
26
  import { defaultHarness, HARNESSES, dashboardLauncherList, launcherDefault } from './harness.js'
24
- import { evalTimeline, readBlobByHash } from '../../spec-eval/src/evaltab.js'
27
+ import { readBlobByHash } from '../../spec-eval/src/evaltab.js'
25
28
  import { putBlob } from '../../spec-eval/src/cache.js'
26
29
  import { fileHumanReading } from '../../spec-eval/src/filing.js'
27
30
  import { fileHumanOk } from '../../spec-eval/src/humanok.js'
28
- import { buildExportModel, renderExportHtml, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
31
+ import { buildExportModel, renderExportHtml, buildSessionEvals, SessionEvalUnavailableError } from '../../spec-eval/src/sessioneval.js'
29
32
  import { appendUpload, cancelUpload, completeUpload, createUpload, evidenceMaxBytes, startUploadReaper, UploadError, uploadStatus } from './uploads.js'
33
+ import { listSessionFiles, openSessionFile, SESSION_FILE_PREVIEW_MAX_BYTES, sessionFilePreviewKind, SessionFileError } from './session-files.js'
30
34
  import { attachViewer, detachViewer, resizeBridge, hideViewer, forwardInput, superviseBridges, type Viewer } from './pty-bridge.js'
31
35
  import { installProcessGuards } from './resilience.js'
32
36
  import { resolveProjectIdentity } from './project-identity.js'
33
37
  import { evalDetailReview, evalsReview, issuesReview } from './reviews.js'
34
38
  import { collectResourceReport, ResourceConflict } from './host-resources.js'
39
+ import { reparentRequest, SessionReparentRequestError } from './session-reparent.js'
35
40
 
36
41
  // last-resort net: an unforeseen async throw (e.g. a worktree vanishing mid-read during a worker
37
42
  // self-merge) is logged and the server KEEPS SERVING instead of exiting and dropping the public port.
@@ -47,6 +52,7 @@ app.onError((error, c) => {
47
52
  // stack would hide exactly the sentence the human needs.
48
53
  if (error instanceof SessionRecordUnusable) return c.json({ error: error.message, code: error.code }, 409)
49
54
  if (error instanceof ResourceConflict) return c.json({ error: error.message, code: error.code }, 409)
55
+ if (error instanceof SessionReparentRequestError) return c.json({ error: error.message }, 400)
50
56
  console.error(error)
51
57
  return c.text('Internal Server Error', 500)
52
58
  })
@@ -137,10 +143,7 @@ app.get('/api/edit', async (c) => {
137
143
  }
138
144
  return c.json({ patch })
139
145
  })
140
- // a node's eval timeline (read half of `spex eval`): eval-sidecar readings joined with a live freshness
141
- // flag, newest-first; `hasEvalFile:false` when none declared. Contract belongs to [[spec-eval]].
142
- app.get('/api/specs/:id/evals', async (c) => c.json(await evalTimeline(c.req.param('id'))))
143
- // the eval seam's WRITE half over HTTP ([[spec-eval]] filing.ts) — the REST pair of the GET above: a
146
+ // the eval seam's WRITE half over HTTP ([[spec-eval]] filing.ts): a
144
147
  // programmatic caller files a reading (verdict + optional transcript) through the SAME append the CLI
145
148
  // uses. The dashboard does not call this — [[event-detail]] reads readings and hosts remarks, never files.
146
149
  app.post('/api/specs/:id/evals', async (c) => {
@@ -230,15 +233,27 @@ app.get('/api/evals', etag(), async (c) => {
230
233
  const page = await evalsReview(c.req.query('q'), c.req.query('page'), { view: c.req.query('view') })
231
234
  return page ? c.json(page) : c.json({ error: 'no such review source' }, 404)
232
235
  })
236
+ // The exact impact graph proves a scope's membership and selector decisions. It is deliberately a named
237
+ // read: paged Evals rows retain their own reasons but never transport this scope-sized projection.
238
+ app.get('/api/evals/impact', etag(), async (c) => {
239
+ const scope = c.req.query('scope')?.trim()
240
+ if (!scope) return c.json({ error: 'scope is required' }, 400)
241
+ await ensureBoardFileWatchers(scope)
242
+ const model = await buildSessionEvals(scope)
243
+ return model
244
+ ? c.json({ scope, impact: model.impact, evalRevision: model.evalRevision })
245
+ : c.json({ error: 'no such review source' }, 404)
246
+ })
233
247
  // ONE bounded detail response for both source roots: the selected scenario's complete A/B history and at
234
- // most five lightweight neighbors. It never serializes another scenario's history or the scoped model.
248
+ // most five lightweight neighbors. A missing worktree scope resolves explicitly to trunk; it never
249
+ // serializes another scenario's history or the scoped model.
235
250
  app.get('/api/evals/detail', etag(), async (c) => {
236
251
  await ensureBoardFileWatchers(c.req.query('scope')?.trim() || undefined)
237
252
  const node = c.req.query('node')?.trim()
238
253
  const scenario = c.req.query('scenario')?.trim()
239
254
  if (!node || !scenario) return c.json({ error: 'node and scenario are required' }, 400)
240
255
  const detail = await evalDetailReview(node, scenario, c.req.query('scope')?.trim() || null)
241
- return detail ? c.json(detail) : c.json({ error: 'no such review source' }, 404)
256
+ return c.json(detail)
242
257
  })
243
258
  // the single-thread read ([[issues]]) behind `spex issue show <id>` — the SAME findIssue lookup, from the
244
259
  // resident forge slice (instant view, background reconcile — the list route's freshness contract). A local
@@ -496,6 +511,14 @@ app.get('/api/sessions/:id/capture', async (c) => {
496
511
  if (r.reason === 'offline') return c.text('session offline (no live pane)', 409)
497
512
  return c.text('capture failed', 502)
498
513
  })
514
+ // A live adapter-owned execution observation, intentionally distinct from the durable conversation timeline.
515
+ // The response carries only the backend-normalized latest working note and typed tool rows; no transcript bytes
516
+ // or parser schema cross this API boundary.
517
+ app.get('/api/sessions/:id/execution', (c) => {
518
+ const execution = readSessionExecution(c.req.param('id') || '')
519
+ return execution ? c.json(execution) : c.json({ error: 'no such session' }, 404)
520
+ })
521
+ app.get('/api/sessions/:id/execution/stream', (c) => sessionExecutionStream(c))
499
522
  // the session's persisted interaction history ([[session-timeline]]): authored status transitions (with the
500
523
  // FULL note text) + delivered prompts, timestamped, oldest first — what a terminal-free surface renders as
501
524
  // the conversation. `?limit=<n>` caps the tail (default 500). 404 for an unknown/non-governed id.
@@ -513,6 +536,39 @@ app.get('/api/sessions/:id', async (c) => {
513
536
  if (!row) return c.json({ error: 'no such session' }, 404)
514
537
  return c.json({ ...row, prompt: await sessionPrompt(id) })
515
538
  })
539
+ app.get('/api/sessions/:id/files', (c) => {
540
+ try { return c.json({ files: listSessionFiles(c.req.param('id')) }) }
541
+ catch (error) {
542
+ if (error instanceof SessionFileError) return c.json({ error: error.message }, error.status)
543
+ throw error
544
+ }
545
+ })
546
+ const sessionFileDownload = (c: any) => {
547
+ try {
548
+ const requested = c.req.query('path')
549
+ if (!requested) return c.json({ error: 'download needs a posted path' }, 400)
550
+ const file = openSessionFile(c.req.param('id'), requested)
551
+ const preview = c.req.query('preview') === '1'
552
+ const previewType = preview ? sessionFilePreviewKind(file.path) : null
553
+ if (preview && !previewType) return c.json({ error: 'no preview for this file type; download it instead' }, 415)
554
+ if (preview && file.size > SESSION_FILE_PREVIEW_MAX_BYTES)
555
+ return c.json({ error: `preview is limited to ${SESSION_FILE_PREVIEW_MAX_BYTES / (1024 * 1024)} MiB; download this ${file.size}-byte file instead` }, 413)
556
+ const headers: Record<string, string> = {
557
+ 'Cache-Control': 'no-store',
558
+ 'Content-Disposition': `${preview ? 'inline' : 'attachment'}; filename*=UTF-8''${encodeURIComponent(file.name)}`,
559
+ 'Content-Length': String(file.size),
560
+ 'Content-Type': previewType?.contentType ?? 'application/octet-stream',
561
+ }
562
+ if (previewType) headers['X-Spexcode-Preview-Kind'] = previewType.kind
563
+ if (c.req.method === 'HEAD') return c.body(null, 200, headers)
564
+ return c.body(Readable.toWeb(createReadStream(file.path)) as ReadableStream, 200, headers)
565
+ } catch (error) {
566
+ if (error instanceof SessionFileError) return c.json({ error: error.message }, error.status)
567
+ throw error
568
+ }
569
+ }
570
+ app.get('/api/sessions/:id/files/download', sessionFileDownload)
571
+ app.on('HEAD', '/api/sessions/:id/files/download', sessionFileDownload)
516
572
  // lifecycle transitions (thin callers of the session state machine)
517
573
  // relaunch ONLY if confirmed offline; demotes working→idle, keeps any declaration. The RESUME GUARD refuses
518
574
  // (409) when the agent is alive or its liveness is unproven — restore-on-alive was the incident's kill-shot.
@@ -523,11 +579,37 @@ app.post('/api/sessions/:id/resume', async (c) => {
523
579
  const r = await resumeSession(c.req.param('id'), { force })
524
580
  return c.json(r, r.ok ? 200 : (r.refused ? 409 : 404))
525
581
  })
526
- // a dispatch to the session's own agent (it runs the merge), never a server merge the server never touches
527
- // main's tree. 200 {dispatched:true} once the prompt is accepted, 409 {dispatched:false} if the agent is unreachable.
582
+ // A reviewed dispatch to the session's own agent (it runs the merge), never a server merge. The caller returns
583
+ // the exact branch/base OIDs with a durable key; sessions.ts validates and accepts them once before ensure-live.
528
584
  app.post('/api/sessions/:id/merge', async (c) => {
529
- const r = await mergeSession(c.req.param('id'))
530
- return c.json(r, r.dispatched ? 200 : 409)
585
+ const requestKey = c.req.header('idempotency-key')
586
+ if (requestKey === undefined) {
587
+ return c.json({ dispatched: false, reason: 'Idempotency-Key is required with reviewed merge authority', code: 'session_merge_invalid_request' }, 400)
588
+ }
589
+ const rawBody = await c.req.text()
590
+ let body: unknown = {}
591
+ if (rawBody.trim()) {
592
+ try { body = JSON.parse(rawBody) }
593
+ catch { return c.json({ dispatched: false, reason: 'body must be valid JSON', code: 'session_merge_invalid_request' }, 400) }
594
+ }
595
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
596
+ return c.json({ dispatched: false, reason: 'body must be a JSON object', code: 'session_merge_invalid_request' }, 400)
597
+ }
598
+ const input = body as Record<string, unknown>
599
+ const keys = Object.keys(input)
600
+ const allowed = new Set(['expectedBranchHead', 'expectedBaseHead'])
601
+ if (keys.some((key) => !allowed.has(key))) {
602
+ return c.json({ dispatched: false, reason: `unknown session-merge field(s): ${keys.filter((key) => !allowed.has(key)).join(', ')}`, code: 'session_merge_invalid_request' }, 400)
603
+ }
604
+ if (typeof input.expectedBranchHead !== 'string' || typeof input.expectedBaseHead !== 'string') {
605
+ return c.json({ dispatched: false, reason: 'expectedBranchHead and expectedBaseHead must be strings', code: 'session_merge_invalid_request' }, 400)
606
+ }
607
+ const r = await mergeSession(c.req.param('id'), {
608
+ requestKey,
609
+ expectedBranchHead: input.expectedBranchHead,
610
+ expectedBaseHead: input.expectedBaseHead,
611
+ })
612
+ return c.json(r, r.dispatched ? 200 : (r.status ?? 409))
531
613
  })
532
614
 
533
615
  // one WS owns one native tmux client (pty-bridge): server→client = that client's rendered PTY bytes (binary);
@@ -633,6 +715,11 @@ app.post('/api/sessions/:id/input', async (c) => {
633
715
  }
634
716
  return c.json({ error: 'input needs kind: "text" | "command" | "keys"' }, 400)
635
717
  })
718
+ app.post('/api/sessions/reparent', async (c) => {
719
+ const result = await reparentRequest(await c.req.json().catch(() => null))
720
+ notifyBoardChanged('sessions')
721
+ return c.json(result)
722
+ })
636
723
  // soft stop: kill the agent's tmux + socket but KEEP the worktree (resumable). Distinct from close, which
637
724
  // removes the worktree. {ok:false} = no such session.
638
725
  app.post('/api/sessions/:id/stop', async (c) => {
@@ -646,7 +733,8 @@ app.post('/api/sessions/:id/interrupt', async (c) => {
646
733
  })
647
734
  app.post('/api/sessions/:id/close', async (c) => {
648
735
  const sessionId = c.req.param('id')
649
- const ok = await closeSession(sessionId)
736
+ const body = await c.req.json().catch(() => ({}))
737
+ const ok = await closeSession(sessionId, body?.source)
650
738
  // The close route owns its write's visible boundary: filesystem watchers can be unavailable, so cache
651
739
  // invalidation must happen before the success response rather than leaving the confirming board to patrol.
652
740
  if (ok) notifyBoardChanged('sessions')