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
@@ -57,7 +57,7 @@ function flushExit(code = 0): Promise<never> {
57
57
  }
58
58
  const has = (name: string) => process.argv.includes(`--${name}`)
59
59
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
60
- const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch'])
60
+ const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch', '--to', '--name', '--base'])
61
61
  function positionals(from: number): string[] {
62
62
  const out: string[] = []
63
63
  for (let i = from; i < process.argv.length; i++) {
@@ -81,6 +81,51 @@ function rejectUnknownFlags(command: string, from: number, allowed: readonly str
81
81
  }
82
82
  }
83
83
 
84
+ type SessionSendArgs =
85
+ | { selector: string; kind: 'text'; text: string }
86
+ | { selector: string; kind: 'keys'; keys: string[] }
87
+
88
+ function sessionSendUsage(detail: string, keys = false): never {
89
+ console.error(`spex session send: ${detail}`)
90
+ console.error(keys
91
+ ? 'usage: spex session send <SEL> --keys "<keys>" (e.g. "Up Up Enter", "C-r", single chars — last resort; try a plain send first)'
92
+ : 'usage: spex session send <SEL> "<msg>" [--api <url> | --port <n>]\n spex session send <SEL> [--api <url> | --port <n>] -- <option-shaped-msg>')
93
+ process.exit(2)
94
+ }
95
+
96
+ function parseSessionSendArgs(args: string[]): SessionSendArgs {
97
+ const valueFlags = new Set(['--api', '--port', '--keys', '--password'])
98
+ const bareFlags = new Set(['--insecure'])
99
+ const values = new Map<string, string>()
100
+ const positionals: string[] = []
101
+ let endOfOptions = false
102
+ for (let i = 0; i < args.length; i++) {
103
+ const token = args[i]
104
+ if (!endOfOptions && token === '--') { endOfOptions = true; continue }
105
+ if (!endOfOptions && token.startsWith('--')) {
106
+ if (!valueFlags.has(token) && !bareFlags.has(token)) sessionSendUsage(`unknown flag ${token}`)
107
+ if (values.has(token)) sessionSendUsage(`${token} may appear only once`, token === '--keys')
108
+ values.set(token, '')
109
+ if (bareFlags.has(token)) continue
110
+ const value = args[++i]
111
+ if (value === undefined || value === '' || value.startsWith('--')) sessionSendUsage(`${token} expects one non-empty value`, token === '--keys')
112
+ values.set(token, value)
113
+ continue
114
+ }
115
+ positionals.push(token)
116
+ }
117
+ if (values.has('--api') && values.has('--port')) sessionSendUsage('--api and --port are alternate routes; choose one')
118
+ const rawKeys = values.get('--keys')
119
+ if (rawKeys !== undefined) {
120
+ if (positionals.length !== 1) sessionSendUsage('raw keys take one selector and no text message', true)
121
+ const keys = rawKeys.split(/\s+/).filter(Boolean)
122
+ if (!keys.length) sessionSendUsage('--keys expects one non-empty value', true)
123
+ return { selector: positionals[0], kind: 'keys', keys }
124
+ }
125
+ if (positionals.length !== 2) sessionSendUsage('plain send requires exactly one selector and one message')
126
+ return { selector: positionals[0], kind: 'text', text: positionals[1] }
127
+ }
128
+
84
129
  const SIGNPOSTS: Record<string, string> = {
85
130
  search: 'spex spec search <query>',
86
131
  owner: 'spex spec owner <path>',
@@ -170,18 +215,22 @@ async function localWatchTargetsOrExit(selectors: string[], verb: string): Promi
170
215
  return targets
171
216
  }
172
217
 
173
- async function resolveSelectorOrExit(selector: string): Promise<string> {
218
+ async function resolveSessionOrExit(selector: string): Promise<import('./sessions.js').Session> {
174
219
  if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch | . for self)'); process.exit(2) }
175
220
  const { resolveClientSession } = await import('./client.js')
176
- const { sessionLabel } = await import('./sessions.js')
221
+ const { sessionTitle } = await import('./sessions.js')
177
222
  const r = await resolveClientSession(selector)
178
- if ('ok' in r) return r.ok.id
223
+ if ('ok' in r) return r.ok
179
224
  if ('none' in r) { console.error(`spex: no such session: ${selector}`); process.exit(2) }
180
225
  console.error(`spex: ambiguous selector "${selector}" matches ${r.ambiguous.length} sessions — be more specific:`)
181
- for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${sessionLabel(s)}`)
226
+ for (const s of r.ambiguous) console.error(` ${s.id.slice(0, 8)} ${sessionTitle(s)}`)
182
227
  process.exit(2)
183
228
  }
184
229
 
230
+ async function resolveSelectorOrExit(selector: string): Promise<string> {
231
+ return (await resolveSessionOrExit(selector)).id
232
+ }
233
+
185
234
  // the [[session-eval]] EXPORT artifact behind `spex eval ls --session <SEL> --export`: fetch the
186
235
  // backend-rendered self-contained HTML, write it (--out, else a tmp file) or open it (--open). Never returns.
187
236
  async function evalExport(id: string): Promise<never> {
@@ -337,11 +386,11 @@ if (cmd === 'serve') {
337
386
  console.log(h)
338
387
  } else console.log(overviewHelp())
339
388
  } else if (cmd === 'guide') {
340
- const { guideText } = await import('./guide.js')
389
+ const { guideText, guideTopics } = await import('./guide.js')
341
390
  if (process.argv[3] === 'config') signpost('spex guide config', 'spex guide settings')
342
391
  const text = guideText(process.argv[3])
343
392
  if (text === null) {
344
- console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, eval, settings, footprint. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
393
+ console.error(`spex guide: no topic '${process.argv[3]}'. Topics: ${guideTopics().join(', ')}. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
345
394
  process.exit(2)
346
395
  }
347
396
  console.log(text)
@@ -425,9 +474,25 @@ if (cmd === 'serve') {
425
474
  console.log(`${rel} — no spec claims this yet (uncovered). If your change is substantive, give it a home before it drifts.`)
426
475
  } else if (owners.length === 0) {
427
476
  // related-only: lint's coverage is satisfied, so the per-edit hook stays silent (lint-consistent) —
428
- // but a human asking gets the honest nuance: nothing tracks this file's drift.
477
+ // but a human asking gets the honest nuance about what does and does not track this file.
429
478
  if (has('actionable')) process.exit(0)
430
- console.log(`${rel} not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). Nothing tracks its drift; if your change is substantive, consider giving it a governing home.`)
479
+ // @@@ both axes - "nothing tracks its drift" is a verdict about the WHOLE tracking model
480
+ // ([[governed-related]]: spec nodes AND eval scenarios), so it cannot be spoken from the spec axis
481
+ // alone — a scenario's code: anchor drives eval freshness with no spec claim involved. Derived, never
482
+ // asserted: a message that enumerates one axis cannot report what the other holds. The empty
483
+ // nodeCode fallback is exact HERE and only here — reaching this branch means no spec node code:-claims
484
+ // the file, so no scenario can anchor to it by inheriting its node's claim, only by an explicit one.
485
+ const { evalNodes, scenarioCodeAxis } = await import('../../spec-eval/src/scenarios.js')
486
+ const anchored: string[] = []
487
+ for (const n of evalNodes(process.cwd())) {
488
+ for (const sc of n.scenarios) {
489
+ if (scenarioCodeAxis(sc.code).paths.includes(rel)) anchored.push(`'${n.id}' scenario '${sc.name}'`)
490
+ }
491
+ }
492
+ const tracking = anchored.length
493
+ ? `Its drift is tracked on the eval axis only: ${anchored.length} scenario${anchored.length === 1 ? '' : 's'} anchor${anchored.length === 1 ? 's' : ''} freshness to it (${anchored.join(', ')}), so changing it makes those readings stale — but no spec body says what it should do`
494
+ : 'Nothing tracks its drift'
495
+ console.log(`${rel} — not governed (no code: claim), but referenced by ${names(related)} (related: coverage only). ${tracking}; if your change is substantive, consider giving it a governing home.`)
431
496
  } else if (whole.length <= maxOwners) {
432
497
  // a sanely-owned file is NOT actionable: --actionable callers (the per-edit spec-of-file hook) stay
433
498
  // silent here, so the annotation fires only on an OVER-owned or uncovered file — rare and worth acting on.
@@ -440,16 +505,21 @@ if (cmd === 'serve') {
440
505
  console.log(`${rel} is governed whole-file by ${whole.length} specs (all claims: ${ids}) — more than one file should hold. This file does TOO MUCH: SPLIT it so each governor owns its own module (or merge the nodes if they're one concern, or give it a single foundation owner + relate the rest).${relLine}`)
441
506
  }
442
507
  } else if (sub === 'lint') {
443
- const { specLint, pendingTouchesGoverned, DRIFT_GUIDANCE } = await import('./lint.js')
508
+ const { specLintReport, pendingTouchesGoverned, DRIFT_GUIDANCE } = await import('./lint.js')
444
509
  const pending = flag('pending')
445
510
  if (pending && process.env.SPEXCODE_GATE_SCOPE_ONLY === '1') {
446
511
  const touches = await pendingTouchesGoverned(process.cwd(), pending)
447
512
  if (!touches) process.exit(76)
448
513
  }
449
- const findings = await specLint(undefined, undefined, {
514
+ const report = await specLintReport(undefined, undefined, {
450
515
  tip: pending || 'HEAD',
451
516
  })
517
+ const { findings } = report
452
518
  const errors = findings.filter((f) => f.level === 'error')
519
+ if (has('json')) {
520
+ console.log(JSON.stringify(report, null, 2))
521
+ await flushExit(errors.length ? 1 : 0)
522
+ }
453
523
  for (const f of findings) console.error(` ${f.level === 'error' ? '✗' : '•'} ${f.rule}: ${f.msg}`)
454
524
  console.error(`spex spec lint: ${errors.length} error(s), ${findings.length - errors.length} warning(s)`)
455
525
  // drift teaches from the ONE `spex spec lint` (no flag). Unanchored drift stays advisory forever; the
@@ -513,7 +583,7 @@ if (cmd === 'serve') {
513
583
  const m = r.model
514
584
  const own = m.items.filter((item) => item.inSession).length
515
585
  console.log(`eval session [${m.id}]`)
516
- console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
586
+ if (m.gates.length) console.log(` gates : ${m.gates.map((g) => `${g.ok ? '✓' : '✗'} ${g.label} — ${g.detail}`).join(' · ')}`)
517
587
  if (own) console.log(` ✦ : ${own} scenario(s) measured by THIS session (unmarked rows = evals filed by other sessions)`)
518
588
  if (!m.items.length) console.log('\n no affected scenarios to evaluate yet')
519
589
  let lastNode: string | null = null
@@ -597,7 +667,7 @@ if (cmd === 'serve') {
597
667
  console.error('spex session new: --node was removed — put a [[<id>]] mention in the prompt — the first mention binds')
598
668
  process.exit(2)
599
669
  }
600
- rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'api', 'port'])
670
+ rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'name', 'base', 'api', 'port'])
601
671
  const { createSession } = await import('./sessions.js')
602
672
  const promptFile = flag('prompt-file')
603
673
  const inline = flag('prompt') ?? positionals(4)[0]
@@ -610,7 +680,7 @@ if (cmd === 'serve') {
610
680
  catch (e) { console.error(`spex session new: --prompt-file ${promptFile}: ${e instanceof Error ? e.message : e}`); process.exit(2) }
611
681
  if (!prompt.trim()) { console.error(`spex session new: --prompt-file ${promptFile === '-' ? 'stdin' : promptFile} is empty — refusing a promptless launch`); process.exit(2) }
612
682
  }
613
- const created = await createSession(prompt, flag('launcher') ?? undefined)
683
+ const created = await createSession(prompt, flag('launcher') ?? undefined, flag('name') ?? undefined, flag('base') ?? undefined)
614
684
  const { ownSessionId, subscribeSessionWatch } = await import('./sessions.js')
615
685
  let watchEstablished = false
616
686
  if (created.parent && created.parent === ownSessionId()) {
@@ -642,6 +712,60 @@ if (cmd === 'serve') {
642
712
  const report = await clientResources()
643
713
  if (has('json')) console.log(JSON.stringify(report, null, 2))
644
714
  else console.log((await import('./host-resources.js')).formatResourceReport(report))
715
+ } else if (sub === 'files') {
716
+ rejectUnknownFlags('spex session files', 4, [])
717
+ const [verb, path, extra] = positionals(4)
718
+ if (extra || !verb || (verb !== 'ls' && !path) || (verb === 'ls' && path)) {
719
+ console.error('usage: spex session files add <path> | ls | retract <path>')
720
+ process.exit(2)
721
+ }
722
+ const { ownSessionId, withSessionRecordLockSync } = await import('./sessions.js')
723
+ const id = ownSessionId()
724
+ if (!id) {
725
+ console.error('spex session files: no governed caller session — run this from the agent session that produced the file')
726
+ process.exit(2)
727
+ }
728
+ const files = await import('./session-files.js')
729
+ if (verb === 'ls') {
730
+ for (const file of files.listSessionFiles(id)) console.log(file)
731
+ } else if (verb === 'add') {
732
+ const result = files.addSessionFile(id, path!, withSessionRecordLockSync)
733
+ console.log(result.added ? `posted ${result.path}` : `already posted ${result.path}`)
734
+ } else if (verb === 'retract') {
735
+ const result = files.retractSessionFile(id, path!, withSessionRecordLockSync)
736
+ if (!result.removed) { console.error(`spex session files retract: path is not posted: ${result.path}`); process.exit(2) }
737
+ console.log(`retracted ${result.path}`)
738
+ } else {
739
+ console.error(`spex session files: unknown verb '${verb}' — add | ls | retract (spex help session)`)
740
+ process.exit(2)
741
+ }
742
+ } else if (sub === 'web') {
743
+ rejectUnknownFlags('spex session web', 4, [])
744
+ const [verb, url, extra] = positionals(4)
745
+ if (extra || !verb || (verb !== 'ls' && !url) || (verb === 'ls' && url)) {
746
+ console.error('usage: spex session web add <url> | ls | retract <url>')
747
+ process.exit(2)
748
+ }
749
+ const { ownSessionId, withSessionRecordLockSync } = await import('./sessions.js')
750
+ const id = ownSessionId()
751
+ if (!id) {
752
+ console.error('spex session web: no governed caller session — run this from the agent session that started the local service')
753
+ process.exit(2)
754
+ }
755
+ const web = await import('./session-web.js')
756
+ if (verb === 'ls') {
757
+ for (const entry of web.listSessionWebs(id)) console.log(entry.url)
758
+ } else if (verb === 'add') {
759
+ const result = web.addSessionWeb(id, url!, withSessionRecordLockSync)
760
+ console.log(result.added ? `posted ${result.url}` : `already posted ${result.url}`)
761
+ } else if (verb === 'retract') {
762
+ const result = web.retractSessionWeb(id, url!, withSessionRecordLockSync)
763
+ if (!result.removed) { console.error(`spex session web retract: URL is not posted: ${result.url}`); process.exit(2) }
764
+ console.log(`retracted ${result.url}`)
765
+ } else {
766
+ console.error(`spex session web: unknown verb '${verb}' — add | ls | retract (spex help session)`)
767
+ process.exit(2)
768
+ }
645
769
  } else if (sub === 'watch') {
646
770
  const [verb, ...rest] = positionals(4)
647
771
  const { ownSessionId, subscribeSessionWatch, listSessionWatches, cancelSessionWatch } = await import('./sessions.js')
@@ -710,13 +834,13 @@ if (cmd === 'serve') {
710
834
  if (first === 'proof') signpost('spex review proof', 'spex eval ls --session <SEL> --export') // dead-words-ok: signpost — one-version tombstone teaching the renamed spelling (0.4.0 removes it)
711
835
  const { clientReview } = await import('./client.js')
712
836
  if (!first) { console.error('usage: spex session review <SEL> (id | id-prefix | node | branch)'); process.exit(2) }
713
- const id = await resolveSelectorOrExit(first)
714
- const r = await clientReview(id)
715
- if (!r) { console.error(`no such session ${id}`); process.exit(1) }
837
+ const session = await resolveSessionOrExit(first)
838
+ const r = await clientReview(session.id)
839
+ if (!r) { console.error(`no such session ${session.id}`); process.exit(1) }
716
840
  if (has('json')) { console.log(JSON.stringify(r, null, 2)) }
717
841
  else {
718
842
  const g = r.gates
719
- console.log(`review ${r.label} [${r.id}]`)
843
+ console.log(`review ${session.title} [${r.id}]`)
720
844
  console.log(` ahead of main : ${r.ahead} commit(s)`)
721
845
  console.log(` uncommitted : ${r.dirtyNonRuntime} non-runtime file(s)`)
722
846
  console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
@@ -732,11 +856,17 @@ if (cmd === 'serve') {
732
856
  for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
733
857
  }
734
858
  } else if (sub === 'merge') {
735
- const { clientMerge } = await import('./client.js')
859
+ const [{ clientMerge, clientReview }, { createHash }] = await Promise.all([import('./client.js'), import('node:crypto')])
736
860
  const sel = positionals(4)[0]
737
861
  if (!sel) { console.error('usage: spex session merge <SEL> (id | id-prefix | node | branch)'); process.exit(2) }
738
862
  const id = await resolveSelectorOrExit(sel)
739
- const r = await clientMerge(id)
863
+ const review = await clientReview(id)
864
+ if (!review) { console.error(`merge dispatch failed: no such session ${id}`); process.exit(1) }
865
+ const r = await clientMerge(id, {
866
+ expectedBranchHead: review.branchHead,
867
+ expectedBaseHead: review.baseHead,
868
+ requestKey: createHash('sha256').update(`spexcode-cli-session-merge\0${id}\0${review.branchHead}\0${review.baseHead}`).digest('hex'),
869
+ })
740
870
  if (r.dispatched) console.log(`merge dispatched to ${id} — its agent is landing the merge`)
741
871
  else console.error(`merge dispatch failed: ${r.reason}`)
742
872
  process.exit(r.dispatched ? 0 : 1)
@@ -744,8 +874,9 @@ if (cmd === 'serve') {
744
874
  // `s` (sessions.ts) backs the state PRODUCERS that stay local (done/park/ask write the global record by
745
875
  // session_id) and the stateKit shared with `spex internal session-*`. `c` (client.ts) backs the
746
876
  // read/control subs that route through the backend. Lazily imported.
877
+ const sendArgs = sub === 'send' ? parseSessionSendArgs(process.argv.slice(4)) : null
747
878
  const c = await import('./client.js')
748
- const id = process.argv[4]
879
+ const id = sendArgs?.selector ?? process.argv[4]
749
880
  if (sub === 'resume') {
750
881
  // bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
751
882
  // `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts resumeSession()).
@@ -822,18 +953,30 @@ if (cmd === 'serve') {
822
953
  const quarantined = await c.clientQuarantine(id, { adapter, thread: flag('thread') ?? null, tmux, worktree, branch })
823
954
  console.log(`quarantined ${quarantined.id} -> ${quarantined.bundle}`)
824
955
  }
956
+ } else if (sub === 'reparent') {
957
+ rejectUnknownFlags('spex session reparent', 4, ['to', 'api', 'port'])
958
+ const children = positionals(4)
959
+ const to = flag('to')
960
+ if (!children.length || !to) {
961
+ console.error('usage: spex session reparent <child-SEL...> --to <parent-SEL>')
962
+ process.exit(2)
963
+ }
964
+ const childIds = [...new Set(await Promise.all(children.map(resolveSelectorOrExit)))]
965
+ const parent = await resolveSelectorOrExit(to)
966
+ const { reparentSessions } = await import('./session-reparent.js')
967
+ const result = await reparentSessions(childIds, parent)
968
+ console.log(`reparented ${result.children.join(', ')} -> ${result.parent}`)
825
969
  } else if (sub === 'send') {
970
+ if (!sendArgs) sessionSendUsage('arguments were not parsed')
826
971
  const full = await resolveSelectorOrExit(id)
827
- if (has('keys')) {
972
+ if (sendArgs.kind === 'keys') {
828
973
  // the LAST-RESORT face of send: forward raw nav-mode keystrokes (tmux send-keys, NEVER the prompt
829
974
  // socket) — how a manager drives a worker wedged in an interactive TUI dialog the prompt channel
830
975
  // can't reach (a select menu wanting one Enter/arrow). UNSTABLE, and able to confirm dangerous
831
976
  // dialogs — try plain `session send` text FIRST; reach for --keys only when text provably can't
832
977
  // land. Tokens = named keys, single chars, C-/M-/S- combos; whitespace-separated, delivered as ONE
833
978
  // ordered batch ([[nav-mode-key-ordering]]). Fail-loud: nothing delivered exits non-zero.
834
- const keys = (flag('keys') ?? '').split(/\s+/).filter(Boolean)
835
- if (keys.length === 0) { console.error('usage: spex session send <SEL> --keys "<keys>" (e.g. "Up Up Enter", "C-r", single chars — last resort; try a plain send first)'); process.exit(2) }
836
- if (await c.clientSendRawKeys(full, keys)) { console.log(`sent ${keys.length} key${keys.length === 1 ? '' : 's'} -> ${full}`); process.exit(0) }
979
+ if (await c.clientSendRawKeys(full, sendArgs.keys)) { console.log(`sent ${sendArgs.keys.length} key${sendArgs.keys.length === 1 ? '' : 's'} -> ${full}`); process.exit(0) }
837
980
  console.error(`spex session send --keys: nothing delivered to ${full} (offline, unknown session, or no valid key token)`)
838
981
  process.exit(1)
839
982
  }
@@ -853,7 +996,7 @@ if (cmd === 'serve') {
853
996
  // the board), NOT the stable sessionLabel that stops at the bare prompt-truncation title.
854
997
  sender = 'ok' in sr ? { id: sr.ok.id, label: s.sessionHeadline(sr.ok) } : { id: senderId, label: null }
855
998
  }
856
- const r = await c.clientSend(full, s.withSenderHint(process.argv[5] ?? '', sender), senderId ?? undefined)
999
+ const r = await c.clientSend(full, s.withSenderHint(sendArgs.text, sender), senderId ?? undefined)
857
1000
  console.log(r.ok ? 'sent' : `dispatch failed: ${r.error}`)
858
1001
  process.exit(r.ok ? 0 : 1)
859
1002
  } else if (sub === 'show') {
@@ -872,7 +1015,7 @@ if (cmd === 'serve') {
872
1015
  if (has('json')) { console.log(JSON.stringify(r.session, null, 2)) }
873
1016
  else {
874
1017
  const x = r.session
875
- console.log(`${x.label} [${x.id}]`)
1018
+ console.log(`${x.title} [${x.id}]`)
876
1019
  console.log(` status : ${x.status} (lifecycle ${x.lifecycle} · liveness ${x.liveness})`)
877
1020
  console.log(` node : ${x.node ?? '—'}`)
878
1021
  console.log(` branch : ${x.branch ?? '—'}`)
@@ -901,7 +1044,7 @@ if (cmd === 'serve') {
901
1044
  await assertLocalBackend()
902
1045
  process.exit(await attachSession(await resolveSelectorOrExit(id)))
903
1046
  } else {
904
- console.error(`spex session: unknown verb '${sub}' — new | ls | show | watch | wait | review | merge | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
1047
+ console.error(`spex session: unknown verb '${sub}' — new | ls | files | web | show | watch | wait | review | merge | reparent | send | interrupt | rename | resume | stop | close | attach | done | park | ask (spex help session)`)
905
1048
  process.exit(2)
906
1049
  }
907
1050
  }
@@ -2,26 +2,113 @@ import { existsSync } from 'node:fs'
2
2
  import { platform } from 'node:os'
3
3
  import { repoRoot } from './git.js'
4
4
  import { resourceBudgets, type ResourceReport } from './host-resources.js'
5
- import { listSessionIds, readPublicRecordEntry } from './layout.js'
5
+ import { envSessionId, listSessionIds, readPublicRecordEntry } from './layout.js'
6
6
  import { cockpitReview, type CockpitReview } from './cockpit.js'
7
- import { apiBase, apiBaseInfo, assertProjectMatch, fromRaw, resolveSession, toSession, type DisplayStatus, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
7
+ import { apiBaseInfo, assertProjectMatch, fromRaw, optionArgv, resolveSession, toSession, type DisplayStatus, type Session, type Resolved, type DispatchResult, type ReviewPayload } from './sessions.js'
8
8
 
9
9
  export class BackendError extends Error {
10
- constructor(message: string, readonly status?: number) {
10
+ constructor(message: string, readonly status?: number, readonly transport?: unknown) {
11
11
  super(message)
12
12
  this.name = 'BackendError' // cli.ts's top-level handler matches on the NAME, so it needs no import of this class
13
13
  }
14
14
  }
15
15
 
16
+ const usageError = (message: string): Error => {
17
+ const error = new Error(message)
18
+ error.name = 'UsageError'
19
+ return error
20
+ }
21
+
22
+ const hasFlag = (name: string): boolean => optionArgv().includes(`--${name}`)
23
+ function flagValue(name: string): string | null {
24
+ const argv = optionArgv()
25
+ const index = argv.indexOf(`--${name}`)
26
+ if (index < 0) return null
27
+ const value = argv[index + 1]
28
+ if (value === undefined || value.startsWith('--')) throw usageError(`--${name} expects a value`)
29
+ return value
30
+ }
31
+
32
+ let gatewayCookie: { base: string; value: string } | null = null
33
+
34
+ function prepareTls(base: string): void {
35
+ if (!hasFlag('insecure')) return
36
+ if (new URL(base).protocol !== 'https:') throw usageError('--insecure requires an https --api endpoint')
37
+ process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0'
38
+ }
39
+
40
+ function passwordFor(target: Awaited<ReturnType<typeof apiBaseInfo>>): string | null {
41
+ const password = flagValue('password') ?? process.env.SPEXCODE_PASSWORD ?? null
42
+ if (!password) return null
43
+ if (target.source !== 'flag') throw usageError('--password and SPEXCODE_PASSWORD require explicit --api routing')
44
+ return password
45
+ }
46
+
47
+ function withCookie(init: RequestInit | undefined, cookie: string): RequestInit {
48
+ const headers = new Headers(init?.headers)
49
+ headers.set('cookie', cookie)
50
+ return { ...init, headers }
51
+ }
52
+
53
+ async function loginGateway(base: string, password: string): Promise<string> {
54
+ let response: Response
55
+ try {
56
+ response = await fetch(`${base}/login`, {
57
+ method: 'POST',
58
+ headers: { 'content-type': 'application/x-www-form-urlencoded' },
59
+ body: new URLSearchParams({ password }).toString(),
60
+ redirect: 'manual',
61
+ })
62
+ } catch (error) {
63
+ throw new BackendError(`gateway login could not reach ${base}: ${(error as Error).message}`, undefined, error)
64
+ }
65
+ if (response.status < 200 || response.status >= 400) throw new BackendError(`gateway login rejected credentials at ${base}`, response.status)
66
+ const headers = response.headers as Headers & { getSetCookie?: () => string[] }
67
+ const cookies = headers.getSetCookie?.() ?? [response.headers.get('set-cookie') ?? '']
68
+ const cookie = cookies.map((value) => value.split(';', 1)[0]).find(Boolean)
69
+ if (!cookie) throw new BackendError(`gateway login at ${base} did not return an authorization cookie`, response.status)
70
+ return cookie
71
+ }
72
+
16
73
  // the ONE seam where "no backend" becomes loud. A network failure (nothing listening at the resolved base)
17
74
  // is the only thing thrown; an HTTP Response of any status is returned for the caller to interpret.
18
75
  async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
19
- const base = await apiBase()
20
- try {
21
- return await fetch(`${base}${path}`, init)
22
- } catch (e) {
23
- throw new BackendError(`no backend reachable at ${base} — run \`spex serve\` in the project, or name one with --api <url> (${(e as Error).message})`)
76
+ const target = await apiBaseInfo()
77
+ const base = target.url
78
+ prepareTls(base)
79
+ const request = async (cookie?: string): Promise<Response> => {
80
+ try {
81
+ return await fetch(`${base}${path}`, cookie ? withCookie(init, cookie) : init)
82
+ } catch (error) {
83
+ throw new BackendError(`no backend reachable at ${base} — run \`spex serve\` in the project, or name one with --api <url> (${(error as Error).message})`, undefined, error)
84
+ }
24
85
  }
86
+ const existing = gatewayCookie?.base === base ? gatewayCookie.value : undefined
87
+ const response = await request(existing)
88
+ if (response.status !== 401) return response
89
+ if (existing) throw new BackendError(`gateway rejected the authenticated request at ${base}`, 401)
90
+ const password = passwordFor(target)
91
+ if (!password) throw new BackendError(`authentication required at ${base} — pass --password <pw> or set SPEXCODE_PASSWORD`, 401)
92
+ const cookie = await loginGateway(base, password)
93
+ gatewayCookie = { base, value: cookie }
94
+ const retried = await request(cookie)
95
+ if (retried.status === 401) throw new BackendError(`gateway rejected the authenticated request at ${base}`, 401)
96
+ return retried
97
+ }
98
+
99
+ export function backendConnectionRefused(error: unknown): boolean {
100
+ if (!(error instanceof BackendError)) return false
101
+ let current = error.transport
102
+ let sawRefusal = false
103
+ const seen = new Set<unknown>()
104
+ while (current && typeof current === 'object' && !seen.has(current)) {
105
+ seen.add(current)
106
+ const code = (current as NodeJS.ErrnoException).code
107
+ if (code && code !== 'ECONNREFUSED') return false
108
+ if (code === 'ECONNREFUSED') sawRefusal = true
109
+ current = (current as { cause?: unknown }).cause
110
+ }
111
+ return sawRefusal
25
112
  }
26
113
  // every MUTATING verb is project-bound ([[remote-client]]'s write guard): resolve the backend, compare its
27
114
  // served root to the cwd project, refuse loudly on a same-host mismatch — an explicit --api/--port skips it.
@@ -211,10 +298,14 @@ export async function clientEvals(id: string): Promise<EvalsResult> {
211
298
  throw new BackendError(`session eval pages changed while fetching ${id}; retry the command`)
212
299
  }
213
300
 
214
- // POST /api/sessions/:id/merge — the cockpit's merge DISPATCH (200 {dispatched:true} / 409 {reason}).
215
- export async function clientMerge(id: string): Promise<{ dispatched: boolean; reason?: string }> {
301
+ // POST /api/sessions/:id/merge — the cockpit's merge DISPATCH. The caller returns the exact pair it just
302
+ // reviewed and one durable request key; the backend accepts that decision once before ensuring the agent live.
303
+ export async function clientMerge(id: string, options: { expectedBranchHead: string; expectedBaseHead: string; requestKey: string }): Promise<{ dispatched: boolean; replayed?: boolean; expectedBranchHead?: string; expectedBaseHead?: string; reason?: string; code?: string }> {
216
304
  await guarded('merge')
217
- const r = await apiFetch(`/api/sessions/${seg(id)}/merge`, post({}))
305
+ const headers: Record<string, string> = { 'content-type': 'application/json', 'Idempotency-Key': options.requestKey }
306
+ const r = await apiFetch(`/api/sessions/${seg(id)}/merge`, {
307
+ method: 'POST', headers, body: JSON.stringify({ expectedBranchHead: options.expectedBranchHead, expectedBaseHead: options.expectedBaseHead }),
308
+ })
218
309
  return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
219
310
  }
220
311
 
@@ -245,10 +336,11 @@ export async function clientInterrupt(id: string): Promise<DispatchResult> {
245
336
  return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` })) as DispatchResult
246
337
  }
247
338
 
248
- // POST /api/sessions/:id/close — the human-only worktree removal. {ok:false} = no such session.
339
+ // POST /api/sessions/:id/close — terminal worktree removal. A client-side session id is only an unverified claim.
249
340
  export async function clientClose(id: string): Promise<boolean> {
250
341
  await guarded('session close')
251
- const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({}))
342
+ const source = envSessionId()
343
+ const r = await apiFetch(`/api/sessions/${seg(id)}/close`, post({ source: source ? { kind: 'unverified-session-claim', id: source } : { kind: 'user' } }))
252
344
  if (!r.ok) throw new BackendError(`backend refused to close ${id}: ${await r.text()}`, r.status)
253
345
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
254
346
  }
@@ -291,6 +383,13 @@ export async function clientRename(id: string, name: string): Promise<boolean> {
291
383
  return !!(await r.json().catch(() => ({ ok: false })))?.ok
292
384
  }
293
385
 
386
+ export async function clientReparent(children: string[], parent: string): Promise<import('./sessions.js').SessionReparentResult> {
387
+ await guarded('session reparent')
388
+ const r = await apiFetch('/api/sessions/reparent', post({ children, parent }))
389
+ if (!r.ok) throw new BackendError(`backend refused to reparent sessions: ${await r.text()}`, r.status)
390
+ return await r.json() as import('./sessions.js').SessionReparentResult
391
+ }
392
+
294
393
  // POST /api/sessions/:id/input {kind:"keys"} — the LAST-RESORT raw nav-key face of send (tmux send-keys,
295
394
  // NEVER the prompt socket): an ordered token batch drives an interactive TUI menu
296
395
  // ([[nav-mode-key-ordering]]). {ok:false} = unknown session, no live pane, or no valid token delivered.
@@ -8,9 +8,16 @@ import { runtimeRoot, sessionArtifactPath, sessionStoreDir } from './layout.js'
8
8
  // or archived without changing what is owed. A session that predates this mechanism owes nothing, because a
9
9
  // queue is only ever filled by an enqueue — which is why no backlog migration exists.
10
10
 
11
- export type PendingMessage = { mid: string; text: string; from: string | null }
11
+ export type PendingMessage = {
12
+ mid: string
13
+ text: string
14
+ from: string | null
15
+ dispatch?: { operation: 'merge'; requestDigest: string }
16
+ }
12
17
 
13
18
  const queuePath = (id: string): string => sessionArtifactPath(id, 'pending.json')
19
+ const revokedSenderRoot = (): string => join(runtimeRoot(), '.revoked-senders')
20
+ const revokedSenderPath = (id: string): string => join(revokedSenderRoot(), id)
14
21
 
15
22
  // @@@ its own lock, deliberately NOT the record lock - the drain holds this across the adapter insert, which
16
23
  // is what makes "claim" real: two processes draining the same session cannot both hand over one message. The
@@ -53,7 +60,10 @@ function read(id: string): PendingMessage[] {
53
60
  return raw.filter((m): m is PendingMessage =>
54
61
  !!m && typeof m === 'object'
55
62
  && typeof (m as PendingMessage).mid === 'string'
56
- && typeof (m as PendingMessage).text === 'string')
63
+ && typeof (m as PendingMessage).text === 'string'
64
+ && ((m as PendingMessage).dispatch === undefined
65
+ || ((m as PendingMessage).dispatch?.operation === 'merge'
66
+ && typeof (m as PendingMessage).dispatch?.requestDigest === 'string')))
57
67
  } catch { return [] } // absent, empty, or unparseable all mean the honest thing: nothing owed
58
68
  }
59
69
 
@@ -68,17 +78,59 @@ function write(id: string, msgs: PendingMessage[]): void {
68
78
  renameSync(tmp, path)
69
79
  }
70
80
 
81
+ // A closed sender may have left debt in many other sessions' queues. The marker is deliberately outside its
82
+ // own store (which close removes) and is checked under the sender's record lock by dispatch: a close cannot
83
+ // return while an old process can still append, and a later sweep cannot hand over what it sees here.
84
+ export function revokeSenderDelivery(id: string): void {
85
+ mkdirSync(revokedSenderRoot(), { recursive: true })
86
+ writeFileSync(revokedSenderPath(id), `${id}\n`)
87
+ }
88
+
89
+ export const senderDeliveryRevoked = (id: string): boolean => existsSync(revokedSenderPath(id))
90
+
91
+ export function pendingSnapshot(id: string): PendingMessage[] { return read(id) }
92
+
93
+ // These two writes require the target's delivery lock. They are the queue half of a larger transaction
94
+ // (currently reparent), which must be able to restore the exact previous debt if a later record write fails.
95
+ export function replacePendingWhileLocked(id: string, msgs: PendingMessage[]): void { write(id, msgs) }
96
+ export function revokePendingFromWhileLocked(id: string, sender: string): number {
97
+ const current = read(id)
98
+ const next = current.filter((msg) => msg.from !== sender)
99
+ write(id, next)
100
+ return current.length - next.length
101
+ }
102
+
71
103
  // The enqueue rides the timeline append ([[dispatch]]): the caller holds the session's RECORD lock across
72
- // both, and the record is written first, so a crash between them leaves a message visible but undelivered —
73
- // never delivered but unrecorded.
104
+ // both, and the record is written first, so delivery is never unrecorded. A keyed dispatch also carries its
105
+ // exact delivery bytes in the timeline receipt, letting its retry restore a queue write lost to a crash.
74
106
  export function enqueue(id: string, msg: PendingMessage): void {
75
107
  write(id, [...read(id), msg])
76
108
  }
77
109
 
110
+ // Caller holds this target's delivery lock. A retry may reach this after either side of the receipt->queue
111
+ // crash boundary; exact mid identity makes reconstruction idempotent without inspecting message text.
112
+ export function ensurePendingWhileLocked(id: string, msg: PendingMessage): boolean {
113
+ const current = read(id)
114
+ if (current.some((pending) => pending.mid === msg.mid)) return false
115
+ write(id, [...current, msg])
116
+ return true
117
+ }
118
+
78
119
  export const pendingMessages = (id: string): PendingMessage[] => read(id)
79
120
 
80
121
  export const owesDelivery = (id: string): boolean => existsSync(queuePath(id))
81
122
 
123
+ // Record transitions and queue mutations take locks in the same direction: record locks first, then these
124
+ // target queue locks. A batch reparent needs all child queues held at once so its pointer/watch/debt change
125
+ // either commits together or restores together.
126
+ export async function withDeliveryLocks<T>(rawIds: string[], body: () => Promise<T>, index = 0, ids = [...new Set(rawIds)].sort()): Promise<T> {
127
+ if (index >= ids.length) return body()
128
+ const release = await acquire(ids[index], 30_000)
129
+ if (!release) throw new Error(`delivery queue ${ids[index]}: timed out waiting for transaction lock`)
130
+ try { return await withDeliveryLocks(ids, body, index + 1, ids) }
131
+ finally { release() }
132
+ }
133
+
82
134
  // Hand over what is owed, in order, exactly once. `insert` reports whether the adapter took the message: only
83
135
  // then is the entry dropped. A refusal ENDS the pass with that entry still queued and everything behind it
84
136
  // still behind it — order is a property of a conversation, so a message is never skipped to deliver a later
@@ -95,6 +147,12 @@ export async function drain(
95
147
  for (;;) {
96
148
  const queued = read(id)
97
149
  if (!queued.length) return { delivered, remaining: 0 }
150
+ if (queued[0].from && senderDeliveryRevoked(queued[0].from)) {
151
+ // Closing a sender voids its undelivered output, not the recipient's immutable conversation history.
152
+ // Drop a revoked head and continue so it cannot permanently block the messages behind it.
153
+ write(id, read(id).filter((m) => m.mid !== queued[0].mid))
154
+ continue
155
+ }
98
156
  let ok = false
99
157
  try { ok = await insert(queued[0]) } catch { ok = false }
100
158
  if (!ok) return { delivered, remaining: queued.length }