spexcode 0.1.5 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +86 -25
  2. package/package.json +5 -6
  3. package/spec-cli/README.md +86 -0
  4. package/spec-cli/bin/spex.mjs +15 -3
  5. package/spec-cli/hooks/dispatch.sh +20 -8
  6. package/spec-cli/hooks/harness.sh +18 -11
  7. package/spec-cli/src/board.ts +47 -18
  8. package/spec-cli/src/boardCache.ts +70 -0
  9. package/spec-cli/src/boardDelta.ts +90 -0
  10. package/spec-cli/src/boardStream.ts +178 -0
  11. package/spec-cli/src/cli.ts +172 -119
  12. package/spec-cli/src/client.ts +6 -4
  13. package/spec-cli/src/gateway.ts +60 -19
  14. package/spec-cli/src/git.ts +105 -92
  15. package/spec-cli/src/guide.ts +175 -12
  16. package/spec-cli/src/harness-select.ts +63 -0
  17. package/spec-cli/src/harness.ts +506 -100
  18. package/spec-cli/src/help.ts +360 -0
  19. package/spec-cli/src/hooks.ts +0 -14
  20. package/spec-cli/src/index.ts +272 -32
  21. package/spec-cli/src/init.ts +41 -1
  22. package/spec-cli/src/issues.ts +250 -0
  23. package/spec-cli/src/layout.ts +70 -28
  24. package/spec-cli/src/lint.ts +12 -10
  25. package/spec-cli/src/listen.ts +28 -0
  26. package/spec-cli/src/localIssues.ts +683 -0
  27. package/spec-cli/src/materialize.ts +182 -27
  28. package/spec-cli/src/mentions.ts +192 -0
  29. package/spec-cli/src/plugin-harness.ts +145 -0
  30. package/spec-cli/src/pty-bridge.ts +378 -81
  31. package/spec-cli/src/self.ts +123 -20
  32. package/spec-cli/src/sessions.ts +461 -298
  33. package/spec-cli/src/specs.ts +55 -14
  34. package/spec-cli/src/supervise.ts +23 -3
  35. package/spec-cli/src/tsx-bin.ts +14 -5
  36. package/spec-cli/src/uninstall.ts +146 -0
  37. package/spec-cli/templates/hooks/post-merge +27 -0
  38. package/spec-cli/templates/hooks/pre-commit +51 -31
  39. package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
  40. package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
  41. package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
  42. package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
  43. package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
  44. package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
  45. package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
  46. package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
  47. package/spec-cli/templates/spec/project/.config/spec.md +3 -3
  48. package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
  49. package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
  50. package/spec-cli/templates/spec/project/spec.md +2 -2
  51. package/spec-dashboard/dist/assets/index-B0tgHeEQ.js +145 -0
  52. package/spec-dashboard/dist/assets/index-BTU-44Os.css +32 -0
  53. package/spec-dashboard/dist/index.html +17 -5
  54. package/spec-forge/src/cache.ts +16 -0
  55. package/spec-forge/src/drivers/github.ts +74 -4
  56. package/spec-forge/src/port.ts +25 -0
  57. package/spec-forge/src/resident.ts +40 -6
  58. package/spec-yatsu/src/cli.ts +227 -38
  59. package/spec-yatsu/src/evaltab.ts +169 -19
  60. package/spec-yatsu/src/filing.ts +48 -0
  61. package/spec-yatsu/src/freshness.ts +55 -20
  62. package/spec-yatsu/src/proof.ts +89 -3
  63. package/spec-yatsu/src/scenariofresh.ts +92 -0
  64. package/spec-yatsu/src/sidecar.ts +75 -11
  65. package/spec-yatsu/src/timeline.ts +47 -0
  66. package/spec-yatsu/src/yatsu.ts +47 -3
  67. package/spec-cli/src/relay.ts +0 -28
  68. package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
  69. package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
  70. package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
@@ -1,21 +1,43 @@
1
1
  export {} // make this a module so top-level await is allowed
2
2
  const cmd = process.argv[2]
3
3
 
4
- // registered before any await so a top-level-await rejection lands here; BackendError matched by name to avoid importing it.
5
- process.on('unhandledRejection', (e: unknown) => {
6
- if (e instanceof Error && e.name === 'BackendError') console.error(`spex: ${e.message}`)
4
+ // Registered before any await so a fatal top-level error lands here. Errors we OWN (BackendError, the
5
+ // loud malformed-config ConfigError) are matched BY NAME — to avoid importing them — and rendered as a
6
+ // one-line `spex: <message>` (a user's config typo must read as their typo, not a SpexCode stack dump);
7
+ // anything else prints in full so a real bug keeps its trace. A synchronous throw inside an awaited call
8
+ // (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
9
+ // paths route through the same printer.
10
+ function fatal(e: unknown): never {
11
+ if (e instanceof Error && (e.name === 'BackendError' || e.name === 'ConfigError')) console.error(`spex: ${e.message}`)
7
12
  else console.error(e)
8
13
  process.exit(1)
9
- })
14
+ }
15
+ process.on('unhandledRejection', fatal)
16
+ process.on('uncaughtException', fatal)
10
17
 
11
18
  // tiny flag reader: --key value (and bare positionals)
12
19
  function flag(name: string): string | undefined {
13
20
  const i = process.argv.indexOf(`--${name}`)
14
21
  return i >= 0 ? process.argv[i + 1] : undefined
15
22
  }
23
+
24
+ // Exit AFTER stdout has flushed. process.exit() force-quits without draining buffered pipe writes, so a
25
+ // large piped dump (`spex issues --json | …`, board, review --json) is silently cut off at the pipe
26
+ // buffer (~64KB). The empty write's callback fires once every prior queued chunk has drained; the returned
27
+ // promise never resolves (process.exit ends the process inside the callback), so `await flushExit(code)`
28
+ // halts execution here exactly like process.exit did — safe to drop in on any unbounded-output verb.
29
+ // EPIPE (a reader that closed early — `| head`, `| jq` exiting) can never drain, so we ALSO exit on the
30
+ // stream error rather than hang: the truncation is the reader's choice then, not ours.
31
+ function flushExit(code = 0): Promise<never> {
32
+ return new Promise<never>(() => {
33
+ const done = () => process.exit(code)
34
+ process.stdout.on('error', done)
35
+ process.stdout.write('', done)
36
+ })
37
+ }
16
38
  const has = (name: string) => process.argv.includes(`--${name}`)
17
39
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
18
- const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--harness-session', '--port', '--api-port'])
40
+ const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--timeout', '--reason', '--out', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api-port', '--preset'])
19
41
  function positionals(from: number): string[] {
20
42
  const out: string[] = []
21
43
  for (let i = from; i < process.argv.length; i++) {
@@ -26,6 +48,23 @@ function positionals(from: number): string[] {
26
48
  return out
27
49
  }
28
50
 
51
+ // After a successful launch, nudge the caller to actually MONITOR the session — launch-then-forget is a real
52
+ // gap (a supervisor or human launches and then never watches, so a review/failure goes unnoticed). Goes to
53
+ // STDERR so the JSON on stdout (which callers parse) stays clean; keyed to whoever's calling — a supervising
54
+ // agent has an own-session id, a human at a terminal does not.
55
+ async function launchMonitorReminder(id: string): Promise<void> {
56
+ const { ownSessionId } = await import('./sessions.js')
57
+ const agent = ownSessionId()
58
+ console.error(`\nspex: launched session ${id} — now MONITOR it, or its review/failure goes unnoticed:`)
59
+ if (agent) {
60
+ // a supervising agent: the per-worker monitor is a backgrounded `spex wait`, which exits on an actionable status.
61
+ console.error(` supervising agent → background \`spex wait ${id}\` (blocks until it hits an actionable status, then exits)`)
62
+ console.error(` or watch the whole stream: \`spex watch\``)
63
+ } else {
64
+ console.error(` \`spex watch\` — the live stream of actionable session transitions (or \`spex wait ${id}\` to block on this one)`)
65
+ }
66
+ }
67
+
29
68
  const greeted = new Set<string>()
30
69
  async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
31
70
  try {
@@ -76,46 +115,12 @@ async function resolveSelectorOrExit(selector: string): Promise<string> {
76
115
  process.exit(2)
77
116
  }
78
117
 
79
- function printHelp(): void {
80
- console.log(`spex SpexCode CLI (spec↔code graph + worktree session state machine)
81
-
82
- Usage: spex <command> [args]
83
-
84
- Specs / graph
85
- guide [spec|yatsu] no topic: the setup workflow; spec/yatsu: the file-format manual for authoring nodes
86
- init [dir] scaffold a repo to adopt SpexCode (seed .spec + install git hooks; default: cwd)
87
- lint check the spec↔code graph (integrity·living·coverage·drift); when committing, gates on heavy commit-local drift
88
- ack <node>… --reason stamp Spec-OK on HEAD for one or more nodes (this change keeps their specs valid); --reason required, not stored
89
- serve run the API server (default :8787). [--port N] sets the listen port (mirrors
90
- dashboard --api-port, so many projects coexist on one host — cwd picks the project)
91
- --public --password <pw> expose it on a public IP behind a password + self-signed TLS (no domain
92
- needed). [--tls-cert F --tls-key F] for your own cert · [--http] to drop TLS
93
- dashboard serve the dashboard UI on its own port (default 5173), proxying /api to a running
94
- \`spex serve\`. [--port N] [--api-port N=8787]. The installed replacement for \`npm run web\`.
95
- board dump the dashboard board state as JSON
96
- forge <sub> trace a forge's issues/PRs onto spec nodes (read-only): links | eval-pending [--host github] [--node <id>] [--json]
97
- yatsu <sub> measure a node's scenarios and keep score: scan | eval [.|<node>] [--scenario N] (--pass|--fail|--note T) [--image P|--result P|-] | show [.|<node>] [--json] | clean [--keep-latest|--all]
98
- hooks <sub> harness-agnostic hook system: compile [--out <file>] (flatten surface:hook nodes into the per-session manifest the dispatcher reads)
99
- self <sub> diagnose how the workflow reaches THIS self-launched agent: doctor (default) | contract | env
100
- review <SEL> manager cockpit: review a session (ahead·merge-base diff·gates·proposal) [--json]
101
- review proof <SEL> render the session's proof of work — self-contained HTML, fully derived (diff·measured yatsu loss·gates) [--open|--out P|--json]
102
- merge <SEL> manager cockpit: gated atomic merge into main (re-checks gates, then closes) [--keep]
103
-
104
- Sessions
105
- ls [SEL…] living-sessions table [--status a,b] [--json]
106
- watch [SEL…] stream actionable transitions — NEVER EXITS; run it in the BACKGROUND, don't block a turn on it (poll one-shot with \`wait\`) [--as NAME] [--status a,b] [--idle] [--interval N]
107
- wait <SEL> block until <SEL> is actionable, print it, exit (one-shot — the non-blocking counterpart to watch; draws the graph edge) [--timeout S=1200] [--interval S]
108
- new "<prompt>" start a session (= session new) [--node X]
109
- session <sub> new | list | reopen | review | done | merge | exit | close | send | capture | prompt
110
- session prompt <SEL> print the session's originating prompt (what it was asked to do)
111
-
112
- SEL = session id (or id-prefix), node, or branch — accepted by every read/control verb (ls·watch·wait·
113
- review·merge·reopen·exit·close·send·capture·prompt); none (or @all) = every session.`)
114
- }
115
-
116
- // a trailing --help/-h prints the summary and exits BEFORE any verb runs, so a help probe never fires a streaming/mutating command.
118
+ // a trailing --help/-h prints help and exits BEFORE any verb runs, so a help probe never fires a
119
+ // streaming/mutating command. It prints THAT command's usage when an entry exists (the second layer
120
+ // of the help journey — see help.ts), falling back to the map for an unknown token.
117
121
  if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
118
- printHelp()
122
+ const { commandHelp, overviewHelp } = await import('./help.js')
123
+ console.log(commandHelp(cmd) ?? overviewHelp())
119
124
  process.exit(0)
120
125
  }
121
126
 
@@ -140,10 +145,22 @@ if (cmd === 'serve') {
140
145
  if (!Number.isInteger(port) || !Number.isInteger(apiPort)) { console.error('spex dashboard: --port and --api-port must be integers'); process.exit(2) }
141
146
  serveDashboardLocal({ port, apiPort })
142
147
  } else if (cmd === undefined || cmd === 'help' || cmd === '--help' || cmd === '-h') {
143
- printHelp()
148
+ // `spex help <cmd>` drills into one command; bare help is the map. Both name the next layer down.
149
+ const { commandHelp, overviewHelp } = await import('./help.js')
150
+ const topic = positionals(3)[0]
151
+ if (cmd === 'help' && topic) {
152
+ const h = commandHelp(topic)
153
+ if (!h) { console.error(`spex help: no command '${topic}' — run \`spex help\` for the map`); process.exit(2) }
154
+ console.log(h)
155
+ } else console.log(overviewHelp())
144
156
  } else if (cmd === 'guide') {
145
157
  const { guideText } = await import('./guide.js')
146
- console.log(guideText(process.argv[3]))
158
+ const text = guideText(process.argv[3])
159
+ if (text === null) {
160
+ console.error(`spex guide: no topic '${process.argv[3]}'. Topics: spec, yatsu, config. Run \`spex guide\` (no topic) for the setup workflow, \`spex help\` for the command map.`)
161
+ process.exit(2)
162
+ }
163
+ console.log(text)
147
164
  } else if (cmd === 'owner') {
148
165
  const { specOwners } = await import('./specs.js')
149
166
  const { loadConfig } = await import('./lint.js')
@@ -200,7 +217,13 @@ if (cmd === 'serve') {
200
217
  // scaffold a repo to adopt SpexCode: copy the shipped DATA templates (seed spec tree + git hooks)
201
218
  // into <targetDir> (default cwd). spex init [targetDir]
202
219
  const { specInit } = await import('./init.js')
203
- await specInit(positionals(3)[0])
220
+ await specInit(positionals(3)[0], flag('preset'))
221
+ } else if (cmd === 'uninstall') {
222
+ // the surgical inverse of init: remove every SpexCode-generated artifact (harness shims/contract/trust, the
223
+ // .gitignore block, the global store, any plugin bundle) — NEVER the user's .spec/.config data or their own
224
+ // prose. Git hooks preserved unless --hooks. spex uninstall [targetDir] [--hooks]
225
+ const { uninstall } = await import('./uninstall.js')
226
+ uninstall(positionals(3)[0], { hooks: has('hooks') })
204
227
  } else if (cmd === 'review' && positionals(3)[0] === 'proof') {
205
228
  const sel = positionals(3)[1]
206
229
  if (!sel) { console.error('usage: spex review proof <selector> [--open | --out <path> | --json]'); process.exit(2) }
@@ -208,7 +231,7 @@ if (cmd === 'serve') {
208
231
  const { clientProof } = await import('./client.js')
209
232
  const r = await clientProof(id, has('json'))
210
233
  if (!r.ok) { console.error(`no proof for ${id} (status ${r.status})`); process.exit(1) }
211
- if (has('json')) { console.log(r.body); process.exit(0) }
234
+ if (has('json')) { console.log(r.body); await flushExit(0) }
212
235
  const { writeFileSync } = await import('node:fs')
213
236
  const { join } = await import('node:path')
214
237
  const { tmpdir } = await import('node:os')
@@ -237,7 +260,6 @@ if (cmd === 'serve') {
237
260
  console.log(` proposal : ${r.proposal.kind ?? '—'}${r.proposal.note ? ` — ${r.proposal.note}` : ''}`)
238
261
  console.log(' gates:')
239
262
  console.log(` conflicts w/ main : ${g.conflictsWithMain ? 'YES' : 'no'}`)
240
- console.log(` typecheck : ${g.typecheck.ok ? 'ok' : `${g.typecheck.errorCount} error(s)`}`)
241
263
  console.log(` lint : ${g.lint.errorCount} error(s), ${g.lint.warningCount} warning(s)`)
242
264
  console.log(` diff (merge-base, ${r.diff.length} file(s)):`)
243
265
  for (const f of r.diff) console.log(` ${f.status.padEnd(12)} +${f.additions} -${f.deletions} ${f.path}`)
@@ -254,16 +276,32 @@ if (cmd === 'serve') {
254
276
  } else if (cmd === 'forge') {
255
277
  // thin route — all logic lives in spec-forge.
256
278
  const { runForge } = await import('../../spec-forge/src/cli.js')
257
- process.exit(await runForge(process.argv.slice(3)))
279
+ await flushExit(await runForge(process.argv.slice(3)))
258
280
  } else if (cmd === 'yatsu') {
259
281
  // thin route — all logic lives in spec-yatsu.
260
282
  const { runYatsu } = await import('../../spec-yatsu/src/cli.js')
261
- process.exit(await runYatsu(process.argv.slice(3)))
262
- } else if (cmd === 'hooks') {
263
- // @@@ hooks - compile the surface:hook nodes into the per-session manifest the (pure-shell) dispatcher
264
- // reads. Thin route, like forge/yatsu. `spex hooks compile [--out <file>]`. Logic in hooks.ts.
265
- const { runHooks } = await import('./hooks.js')
266
- process.exit(await runHooks(process.argv.slice(3)))
283
+ await flushExit(await runYatsu(process.argv.slice(3)))
284
+ } else if (cmd === 'blob') {
285
+ // @@@ blob - the bare evidence-transport verb ([[blob-put]]): put bytes in the shared content-addressed
286
+ // cache and print the hash, decoupled from filing a reading. Thin route the cache lives in spec-yatsu.
287
+ const { runBlob } = await import('../../spec-yatsu/src/cli.js')
288
+ await flushExit(runBlob(process.argv.slice(3)))
289
+ } else if (cmd === 'issues') {
290
+ // @@@ issues - the ONE issues surface ([[issues]]): bare it is THE read — local + forge issues as ONE
291
+ // store-tagged list, the supervisor's/human's drain view; a write first-positional (open|reply|sign|
292
+ // resolve|on|off|status, [[local-issues]]) routes to the local store's write verbs, `promote` moves a
293
+ // thread cross-store. (The pre-rename `spex propose` alias is gone — a deployed post-merge hook still
294
+ // calling it prints an unknown-command line, advisory-only, until `npm run hooks` reinstalls it.)
295
+ const { runIssues } = await import('./issues.js')
296
+ await flushExit(await runIssues(process.argv.slice(3)))
297
+ } else if (cmd === 'remark' || cmd === 'resolve' || cmd === 'retract') {
298
+ // @@@ remark - the resolvable interaction primitive ([[remark-substrate]]): pin a concern to a HOST (a
299
+ // local issue, or a scenario `<node> --scenario <name>`) that a second agent can `resolve` and the author
300
+ // can `retract`. CLI-first — the whole author→resolve→retract loop is these thin store-write wrappers, so
301
+ // the dashboard adds no capability. `spex remark <host> --body -|<text> [--code-sha <sha>]`.
302
+ const m = await import('./localIssues.js')
303
+ const run = cmd === 'remark' ? m.runRemark : cmd === 'resolve' ? m.runResolve : m.runRetract
304
+ await flushExit(await run(process.argv.slice(3)))
267
305
  } else if (cmd === 'materialize') {
268
306
  // @@@ materialize - the pay-per-change render: surface nodes → manifest + AGENTS.md/CLAUDE.md block +
269
307
  // shims + Codex trust, for cwd's project. The cheap shell gate (dispatch.sh) invokes it only on change.
@@ -275,17 +313,18 @@ if (cmd === 'serve') {
275
313
  // hooks+handler-existence · backend) over the same HARNESSES materialize renders through; contract prints
276
314
  // the surface:system text; env dumps raw facts. Thin route, like forge/yatsu/hooks.
277
315
  const { runSelf } = await import('./self.js')
278
- process.exit(await runSelf(process.argv.slice(3)))
316
+ await flushExit(await runSelf(process.argv.slice(3)))
279
317
  } else if (cmd === 'board') {
280
318
  const { buildBoard } = await import('./board.js')
281
319
  console.log(JSON.stringify(await buildBoard(), null, 2))
320
+ await flushExit(0)
282
321
  } else if (cmd === 'search') {
283
322
  const { searchSpecs } = await import('./search.js')
284
323
  const query = positionals(3).join(' ')
285
324
  if (!query.trim()) { console.error('usage: spex search <query> [--json] [--limit N]'); process.exit(2) }
286
325
  const limit = Number(flag('limit')) || 10
287
326
  const results = await searchSpecs(query, { limit, onStats: (s) => console.error(`[spec-search] compute ${s.ms.toFixed(1)}ms · ${s.nodes} nodes · ${s.tokens} tokens (excludes process start)`) })
288
- if (has('json')) { console.log(JSON.stringify(results)); process.exit(0) }
327
+ if (has('json')) { console.log(JSON.stringify(results)); await flushExit(0) }
289
328
  if (!results.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
290
329
  results.forEach((r, i) => {
291
330
  console.log(`${String(i + 1).padStart(2)}. ${r.title} [${r.id}] · score ${r.score}`)
@@ -293,21 +332,7 @@ if (cmd === 'serve') {
293
332
  if (r.snippet) console.log(` ${r.snippet}`)
294
333
  })
295
334
  process.exit(0)
296
- } else if (cmd === 'relay') {
297
- const { relaySearch } = await import('./relay.js')
298
- const query = positionals(3).join(' ')
299
- if (!query.trim()) { console.error('usage: spex relay <query> [--json] [--limit N]'); process.exit(2) }
300
- const limit = Number(flag('limit')) || 3
301
- const hits = await relaySearch(query, { limit })
302
- if (has('json')) { console.log(JSON.stringify(hits)); process.exit(0) }
303
- if (!hits.length) { console.log(`no spec node matches "${query}"`); process.exit(0) }
304
- hits.forEach((h, i) => {
305
- console.log(`${String(i + 1).padStart(2)}. ${h.title} [${h.id}] · score ${h.score}`)
306
- if (h.code.length) h.code.forEach((c) => console.log(` ${c}`))
307
- else console.log(` (no governed code: files — a pure-prose node)`)
308
- })
309
- process.exit(0)
310
- } else if (cmd === 'ls' || cmd === 'sessions') {
335
+ } else if (cmd === 'ls') {
311
336
  // pretty list of living sessions + states. `spex ls [SEL...] [--status a,b] [--json]`
312
337
  // the board comes from the backend (so `spex ls` shows the sessions of whatever SPEXCODE_API_URL points at,
313
338
  // incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
@@ -353,7 +378,9 @@ if (cmd === 'serve') {
353
378
  // it falls back to an in-process launch only when no backend answers.
354
379
  const { createSession } = await import('./sessions.js')
355
380
  const prompt = flag('prompt') ?? positionals(3)[0] ?? ''
356
- console.log(JSON.stringify(await createSession(flag('node') ?? null, prompt, flag('harness') ?? undefined), null, 2))
381
+ const created = await createSession(flag('node') ?? null, prompt, flag('harness') ?? undefined, flag('launcher') ?? undefined)
382
+ console.log(JSON.stringify(created, null, 2))
383
+ await launchMonitorReminder(created.id)
357
384
  } else if (cmd === 'session') {
358
385
  const sub = process.argv[3]
359
386
  // `s` (sessions.ts) backs the state PRODUCERS that stay local (state/done/park/fail/ask/idle write the
@@ -368,19 +395,24 @@ if (cmd === 'serve') {
368
395
  const sess = flag('session')
369
396
  // appended to a done/ask/block declaration: states (not commands) that the next tool call's mark-active hook re-flips the global record to active, so a re-read won't show this.
370
397
  const DECLARED = ' — recorded; the human sees it in the dashboard. This state lives in your session\'s global record; your next tool call flips that record back to active (the mark-active hook, by design), so it is normal for this declaration not to persist.'
398
+ // appended ONLY to a propose-close declaration: a worktree about to be discarded may still own ephemeral things the agent started to test this change; nudge (not gate) it to reclaim them before the worktree goes, keyed on whether the thing should outlive the task — never on who started it (a deliberately long-running service / a production build is started-by-you yet must be left alone). Project-agnostic on purpose.
399
+ const CLOSE_CLEANUP = '\n\nBefore this worktree closes, check whether you left anything running that you started to test this change — a background process, a dev or preview server, a bound port, a scratch session. If nothing depends on it anymore, shut it down, or it keeps running as an orphan. Leave anything meant to keep running: a service you deliberately stood up, a production build, anything other work relies on. What matters is whether it still needs to exist after this task, not whether you started it. If unsure, leave it. This is a reminder to check, not a required step.'
371
400
  if (sub === 'new') {
372
401
  // route through the backend (auth env + concurrency cap); in-process only if no backend is reachable.
373
402
  // prompt = --prompt OR the first positional (after `session new`), so `session new "<prompt>"` works the
374
403
  // SAME as the `spex new "<prompt>"` shorthand — one prompt-resolution rule, not two.
375
- console.log(JSON.stringify(await s.createSession(flag('node') ?? null, flag('prompt') ?? positionals(4)[0] ?? '', flag('harness') ?? undefined), null, 2))
376
- } else if (sub === 'list') {
377
- console.log(JSON.stringify(await c.clientListSessions(), null, 2))
378
- } else if (sub === 'reopen' || sub === 'resume') {
379
- // "back to working": clear proposal -> active, relaunch if offline (the backend owns the relaunch)
404
+ const created = await s.createSession(flag('node') ?? null, flag('prompt') ?? positionals(4)[0] ?? '', flag('harness') ?? undefined, flag('launcher') ?? undefined)
405
+ console.log(JSON.stringify(created, null, 2))
406
+ await launchMonitorReminder(created.id)
407
+ } else if (sub === 'reopen') {
408
+ // bring the agent back up (relaunch ONLY if confirmed offline, the backend owns it); demotes a working
409
+ // `active` to idle but leaves a standing declaration/proposal untouched (see sessions.ts reopen()). The
410
+ // RESUME GUARD refuses a relaunch on a LIVE/unproven agent (that would kill a live worker) — `--force`
411
+ // overrides for a genuinely wedged process. A following prompt is what actually re-drives it.
380
412
  const full = await resolveSelectorOrExit(id)
381
- console.log(await c.clientReopen(full) ? `${full} -> working` : `no such session ${full}`)
382
- } else if (sub === 'review') {
383
- console.log(await s.propose(id, 'merge') ? `${id} -> review` : `no such session ${id}`)
413
+ const r = await c.clientReopen(full, process.argv.includes('--force'))
414
+ if (r.ok) console.log(`${full} -> reopened`)
415
+ else { console.error(`spex session reopen: ${r.error || `no such session ${full}`}`); process.exit(2) }
384
416
  } else if (sub === 'state') {
385
417
  // the agent authors ITS OWN state: active|awaiting|parked|error [--propose] [--note] [--session]
386
418
  const st = process.argv[4] as any
@@ -389,7 +421,8 @@ if (cmd === 'serve') {
389
421
  } else if (sub === 'done') {
390
422
  // sugar for awaiting; --propose merge|nothing|close, optional --note
391
423
  const p = (flag('propose') as any) || 'nothing'
392
- console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}` : 'no session record')
424
+ const closeNote = p === 'close' ? CLOSE_CLEANUP : ''
425
+ console.log(s.markDone(p, sess) ? `done (${p})${DECLARED}${closeNote}` : 'no session record')
393
426
  } else if (sub === 'park') {
394
427
  // sugar: the agent is waiting on a background task; it will self-resume (NOT idle/awaiting)
395
428
  console.log(s.markState('parked', { note: flag('note'), sessionId: sess }) ? `parked${DECLARED}` : 'no session record')
@@ -415,15 +448,6 @@ if (cmd === 'serve') {
415
448
  // never clobbering a deliberate awaiting/asking/parked/error declaration. Distinct from `ask`
416
449
  // (the agent deliberately asking the human) — idle is the undeclared stop the Stop gate missed.
417
450
  console.log(s.markIdle(sess) ? 'idle' : 'noop (no session record, or not active)')
418
- } else if (sub === 'merge') {
419
- // merge dispatch (same as top-level `spex merge`): reopen the session and hand its OWN agent the merge
420
- // prompt — the agent runs the --no-ff merge, resolves conflicts, verifies main advanced, and proposes
421
- // close. The SERVER never touches main. Fail-loud: an unreachable agent prints the reason, exits non-zero.
422
- const full = await resolveSelectorOrExit(id)
423
- const r = await c.clientMerge(full)
424
- if (r.dispatched) console.log(`merge dispatched to ${full} — its agent is landing the merge`)
425
- else console.error(`merge dispatch failed: ${r.reason}`)
426
- process.exit(r.dispatched ? 0 : 1)
427
451
  } else if (sub === 'exit') {
428
452
  // the SOFT stop: kill the agent's tmux + socket but KEEP the worktree, so the session goes offline and
429
453
  // can be resumed (reopen/relaunch). Distinct from `close`, which removes the worktree.
@@ -466,35 +490,64 @@ if (cmd === 'serve') {
466
490
  if (!r.ok) { console.error(`no prompt recorded for ${full}`); process.exit(1) }
467
491
  process.stdout.write(r.prompt.endsWith('\n') ? r.prompt : r.prompt + '\n')
468
492
  } else {
469
- console.error('spex session: new|list|reopen|review|done|park|ask|idle|merge|exit|close|send|capture|prompt'); process.exit(2)
493
+ console.error('spex session: new|reopen|done|park|ask|idle|exit|close|send|capture|prompt'); process.exit(2)
470
494
  }
471
- } else if (cmd === 'codex-launch') {
472
- // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
473
- // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
474
- // SPEXCODE_SESSION_ID), fire the launch prompt as the FIRST turn — materializing the rollout — and print the
475
- // thread id. The launch script then `resume`s it in the visible TUI.
476
- const { codexStartThread, codexTurn } = await import('./harness.js')
477
- const { markHarnessSessionId } = await import('./sessions.js')
478
- const sock = process.argv[3], cwd = process.argv[4]
479
- const prompt = process.argv.slice(5).join(' ')
480
- if (!sock || !cwd) { console.error('usage: spex codex-launch <sock> <cwd> [prompt...]'); process.exit(2) }
481
- const r = await codexStartThread(sock, cwd)
482
- if (!r.ok) { console.error(r.error); process.exit(1) }
483
- const sid = process.env.SPEXCODE_SESSION_ID
484
- if (sid) markHarnessSessionId(sid, r.threadId)
485
- if (prompt) {
486
- const t = await codexTurn(sock, r.threadId, prompt, cwd)
487
- if (!t.ok) { console.error(t.error); process.exit(1) }
495
+ } else if (cmd === 'internal') {
496
+ // @@@ internal - the machine-plumbing namespace: verbs only generated hooks and launch scripts call,
497
+ // kept OUT of the porcelain top level so `spex help`'s vocabulary is exactly what a human/agent types.
498
+ const sub = process.argv[3]
499
+ if (sub === 'trunk') {
500
+ // print the resolved source-of-truth branch (layout.ts mainBranch(): config override → the main
501
+ // checkout's current branch 'main'). The pre-commit main-guard captures this so it blocks direct
502
+ // commits on whatever the repo's trunk is actually named, never a hardcoded 'main'. One value, one
503
+ // line; GET /api/layout exposes the same resolution.
504
+ const { mainBranch } = await import('./layout.js')
505
+ console.log(mainBranch())
506
+ } else if (sub === 'codex-launch') {
507
+ // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
508
+ // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
509
+ // SPEXCODE_SESSION_ID), fire the launch prompt as the FIRST turn — materializing the rollout — and print the
510
+ // thread id. The launch script then `resume`s it in the visible TUI.
511
+ const { codexStartThread, codexTurn, waitForCodexRollout, codexBinary, codexSupportsBypassHookTrust } = await import('./harness.js')
512
+ const { markHarnessSessionId } = await import('./sessions.js')
513
+ const sock = process.argv[4], cwd = process.argv[5]
514
+ const prompt = process.argv.slice(6).join(' ')
515
+ if (!sock || !cwd) { console.error('usage: spex internal codex-launch <sock> <cwd> [prompt...]'); process.exit(2) }
516
+ // On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
517
+ // the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
518
+ // the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
519
+ const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(process.env.SPEXCODE_CODEX_CMD || 'codex --yolo'))
520
+ const r = await codexStartThread(sock, cwd, bypassHookTrust)
521
+ if (!r.ok) { console.error(r.error); process.exit(1) }
522
+ if (prompt) {
523
+ const t = await codexTurn(sock, r.threadId, prompt, cwd)
524
+ if (!t.ok) { console.error(t.error); process.exit(1) }
525
+ // The visible TUI resumes this thread from its ON-DISK rollout; a freshly-spawned app-server acks the turn
526
+ // but persists the rollout a few seconds LATE (verified live: the SAME thread's file lands at ~2-4s, not
527
+ // lost). WAIT for it to land BEFORE storing the id / printing it, else FAIL LOUD — never store a
528
+ // non-resumable harness_session_id (that permanently wedges every reopen). The 15s budget exceeds launch.sh's
529
+ // fast-fail threshold, so a real failure exits past it and the retry loop won't spray duplicate-prompt threads.
530
+ if (!await waitForCodexRollout(r.threadId, 20000)) {
531
+ console.error(`codex thread ${r.threadId} started but persisted no rollout within 20s — app-server not ready; not storing a non-resumable id`)
532
+ process.exit(1)
533
+ }
534
+ }
535
+ const sid = process.env.SPEXCODE_SESSION_ID
536
+ if (sid) markHarnessSessionId(sid, r.threadId)
537
+ console.log(r.threadId)
538
+ } else if (sub === 'codex-turn') {
539
+ // fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
540
+ // tests / scripts). steer-vs-start is chosen live from the thread read.
541
+ const { codexTurn } = await import('./harness.js')
542
+ const sock = process.argv[4], tid = process.argv[5], text = process.argv.slice(6).join(' ')
543
+ if (!sock || !tid || !text) { console.error('usage: spex internal codex-turn <sock> <threadId> <text...>'); process.exit(2) }
544
+ const r = await codexTurn(sock, tid, text)
545
+ if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
546
+ } else {
547
+ const { commandHelp } = await import('./help.js')
548
+ console.error(commandHelp('internal'))
549
+ process.exit(2)
488
550
  }
489
- console.log(r.threadId)
490
- } else if (cmd === 'codex-turn') {
491
- // fire a follow-up turn on an OWNED thread over the per-project socket (the delivery channel, exposed for
492
- // tests / scripts). steer-vs-start is chosen live from the thread read.
493
- const { codexTurn } = await import('./harness.js')
494
- const sock = process.argv[3], tid = process.argv[4], text = process.argv.slice(5).join(' ')
495
- if (!sock || !tid || !text) { console.error('usage: spex codex-turn <sock> <threadId> <text...>'); process.exit(2) }
496
- const r = await codexTurn(sock, tid, text)
497
- if (r.ok) { console.log('ok') } else { console.error(r.error); process.exit(1) }
498
551
  } else {
499
552
  console.error(`spex: unknown command '${cmd}' (try: spex help)`)
500
553
  process.exit(2)
@@ -74,10 +74,12 @@ export async function clientMerge(id: string): Promise<{ dispatched: boolean; re
74
74
  return await r.json().catch(() => ({ dispatched: false, reason: `bad backend response (${r.status})` }))
75
75
  }
76
76
 
77
- // POST /api/sessions/:id/resume — relaunch/back-to-working. {ok:false} = no such session.
78
- export async function clientReopen(id: string): Promise<boolean> {
79
- const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({}))
80
- return !!(await r.json().catch(() => ({ ok: false })))?.ok
77
+ // POST /api/sessions/:id/resume — bring the agent back (relaunch ONLY if confirmed offline); demotes
78
+ // working→idle, keeps any declaration. The RESUME GUARD REFUSES (409 {refused:true}) on a live/unproven agent;
79
+ // `force` overrides for a wedged-but-alive process. {ok:false} otherwise = no such session (404).
80
+ export async function clientReopen(id: string, force = false): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
81
+ const r = await apiFetch(`/api/sessions/${seg(id)}/resume`, post({ force }))
82
+ return await r.json().catch(() => ({ ok: false, error: `bad backend response (${r.status})` }))
81
83
  }
82
84
 
83
85
  // POST /api/sessions/:id/exit — the soft stop: kill tmux + socket, KEEP the worktree (session goes offline,
@@ -8,11 +8,13 @@ import https from 'node:https'
8
8
  import net from 'node:net'
9
9
  import { createHmac, timingSafeEqual } from 'node:crypto'
10
10
  import { execFileSync, spawnSync } from 'node:child_process'
11
- import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
11
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from 'node:fs'
12
+ import { gzipSync, createGzip } from 'node:zlib'
12
13
  import { join, normalize, extname } from 'node:path'
13
14
  import { fileURLToPath } from 'node:url'
14
15
  import { homedir } from 'node:os'
15
16
  import { loginPage } from './login-page.js'
17
+ import { listenOrExit } from './listen.js'
16
18
 
17
19
  // @@@ resolvePublicConfig - the cert/gate is a RESOLVED value, never hardcoded. Reads the same precedence
18
20
  // chain the spec promises: flag > env > spexcode.json > self-signed default. Returns null when public mode
@@ -99,8 +101,8 @@ function cookieOf(header: string | undefined, name: string): string | null {
99
101
  }
100
102
  return null
101
103
  }
102
- function isAuthed(req: http.IncomingMessage, token: string): boolean {
103
- const c = cookieOf(req.headers.cookie, COOKIE)
104
+ function isAuthed(req: http.IncomingMessage, token: string, cookieName: string): boolean {
105
+ const c = cookieOf(req.headers.cookie, cookieName)
104
106
  return c != null && constEq(c, token)
105
107
  }
106
108
 
@@ -117,14 +119,19 @@ export function resolveDistDir(): string {
117
119
  return join(pkgRoot, '..', 'spec-dashboard', 'dist')
118
120
  }
119
121
 
120
- export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string }
122
+ export type GatewayOpts = { publicPort: number; upstreamPort: number; password: string; tls: { cert: string; key: string } | null; distDir: string; host?: string; label?: string; onBindFail?: () => void }
121
123
 
122
124
  export function startGateway(opts: GatewayOpts): void {
123
125
  // gated ONLY when a password is set; otherwise the login layer doesn't exist and the dashboard is served open.
124
126
  const gated = !!opts.password
125
127
  const token = gated ? authToken(opts.password) : ''
126
128
  const secure = !!opts.tls
127
- const setCookie = `${COOKIE}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000${secure ? '; Secure' : ''}`
129
+ // the auth cookie is HOST-scoped (RFC 6265 ignores the port), so two gateways on one IP would share a
130
+ // single 'spex_auth' jar entry and clobber each other's login. Key the name by the public port — the
131
+ // unique discriminator on a host, exactly what the user's two URLs differ by — so same-host instances
132
+ // (e.g. :8787 and :8788) stay logged in concurrently and a logout clears only its own.
133
+ const cookieName = `${COOKIE}_${opts.publicPort}`
134
+ const setCookie = `${cookieName}=${token}; HttpOnly; Path=/; SameSite=Lax; Max-Age=2592000${secure ? '; Secure' : ''}`
128
135
 
129
136
  const handler = (req: http.IncomingMessage, res: http.ServerResponse) => {
130
137
  const url = (req.url || '/').split('?')[0]
@@ -132,23 +139,31 @@ export function startGateway(opts: GatewayOpts): void {
132
139
  // login surface — the only routes reachable without a cookie. Absent entirely when ungated.
133
140
  if (url === '/login' && req.method === 'POST') return doLogin(req, res, opts.password, setCookie)
134
141
  if (url === '/login') return sendHtml(res, 200, loginPage())
135
- if (url === '/logout') { res.writeHead(302, { 'Set-Cookie': `${COOKIE}=; Path=/; Max-Age=0`, Location: '/login' }); return res.end() }
136
- if (!isAuthed(req, token)) {
142
+ if (url === '/logout') { res.writeHead(302, { 'Set-Cookie': `${cookieName}=; Path=/; Max-Age=0`, Location: '/login' }); return res.end() }
143
+ if (!isAuthed(req, token, cookieName)) {
137
144
  if (url.startsWith('/api')) { res.writeHead(401, { 'Content-Type': 'application/json' }); return res.end('{"error":"authentication required"}') }
138
145
  res.writeHead(302, { Location: '/login' }); return res.end()
139
146
  }
140
147
  }
141
148
  if (url.startsWith('/api')) return proxyHttp(req, res, opts.upstreamPort)
142
- return serveStatic(res, opts.distDir, url)
149
+ return serveStatic(req, res, opts.distDir, url)
143
150
  }
144
151
 
145
- const server = secure ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key }, handler) : http.createServer(handler)
152
+ // server-side connection reaping ([[spec-cli]] / [[public-mode]]) - the internet-facing gateway is the
153
+ // public server in public mode, so it carries the SAME reaping as the child: an abandoned/slow connection
154
+ // dies here instead of piling up. Idle keep-alive / slow-header / never-completing request only; the gated
155
+ // WS upgrade (handled below) is an active connection, not reaped by these. Set AT CONSTRUCTION so the
156
+ // connection-checking sweep is armed with our cadence (a post-hoc set leaves it under-effective).
157
+ const reap = { keepAliveTimeout: 10000, headersTimeout: 20000, requestTimeout: 60000, connectionsCheckingInterval: 10000 }
158
+ const server = secure
159
+ ? https.createServer({ cert: opts.tls!.cert, key: opts.tls!.key, ...reap }, handler)
160
+ : http.createServer(reap, handler)
146
161
 
147
162
  // @@@ WS gate - the terminal socket rides an HTTP upgrade. Gate it by the SAME cookie (the browser sends
148
163
  // it on the same-origin handshake), then raw-pipe to the loopback supervisor, replaying the buffered
149
164
  // upgrade request so the child completes the WebSocket handshake. Mirrors supervise.ts's byte pipe.
150
165
  server.on('upgrade', (req, socket, head) => {
151
- if (gated && !isAuthed(req, token)) { socket.destroy(); return }
166
+ if (gated && !isAuthed(req, token, cookieName)) { socket.destroy(); return }
152
167
  const up = net.connect(opts.upstreamPort, '127.0.0.1', () => {
153
168
  up.write(`${req.method} ${req.url} HTTP/1.1\r\n` + rawHeaders(req))
154
169
  if (head && head.length) up.write(head)
@@ -168,8 +183,9 @@ export function startGateway(opts: GatewayOpts): void {
168
183
  console.log(`[gateway] ${label} on ${scheme}://${isLocal ? 'localhost' : '0.0.0.0'}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`)
169
184
  if (!secure && !isLocal) console.log('[gateway] (TLS off — --http)')
170
185
  }
171
- if (opts.host) server.listen(opts.publicPort, opts.host, onListen)
172
- else server.listen(opts.publicPort, onListen)
186
+ // a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
187
+ // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex dashboard` fail a port clash identically.
188
+ listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen })
173
189
  }
174
190
 
175
191
  function rawHeaders(req: http.IncomingMessage): string {
@@ -189,26 +205,51 @@ function doLogin(req: http.IncomingMessage, res: http.ServerResponse, password:
189
205
  })
190
206
  }
191
207
 
192
- // reverse-proxy an /api request to the loopback supervisor (which forwards to the live child).
208
+ // @@@ gzip at the gateway - compression is TRANSPORT, so it lives here, once, for every deployment the
209
+ // loopback upstream and the product semantics never know it exists. Text-ish payloads only; three
210
+ // structural exclusions, each load-bearing: an SSE stream must not sit in a zlib buffer (event latency),
211
+ // an already-encoded response is not re-encoded, and binary media (video/image evidence) gains nothing
212
+ // and would fight Range requests.
213
+ const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg)/
214
+ const wantsGzip = (req: http.IncomingMessage) => /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))
215
+
216
+ // reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
217
+ // stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
193
218
  function proxyHttp(req: http.IncomingMessage, res: http.ServerResponse, upstreamPort: number) {
194
219
  const up = http.request({ host: '127.0.0.1', port: upstreamPort, path: req.url, method: req.method, headers: req.headers }, (upRes) => {
195
- res.writeHead(upRes.statusCode || 502, upRes.headers)
196
- upRes.pipe(res)
220
+ const type = String(upRes.headers['content-type'] || '')
221
+ const skip = !wantsGzip(req) || upRes.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream')
222
+ if (skip) { res.writeHead(upRes.statusCode || 502, upRes.headers); upRes.pipe(res); return }
223
+ const headers = { ...upRes.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' }
224
+ delete headers['content-length'] // streamed; the encoded length isn't knowable up front
225
+ res.writeHead(upRes.statusCode || 502, headers)
226
+ upRes.pipe(createGzip()).pipe(res)
197
227
  })
198
228
  up.on('error', () => { if (!res.headersSent) res.writeHead(502); res.end('upstream unreachable') })
199
229
  req.pipe(up)
200
230
  }
201
231
 
202
232
  // serve the built dashboard (vite dist). Unknown non-file paths fall back to index.html (SPA). Path
203
- // traversal is blocked by normalising and confining to distDir.
204
- function serveStatic(res: http.ServerResponse, distDir: string, urlPath: string) {
233
+ // traversal is blocked by normalising and confining to distDir. Compressible files ship gzipped, memoized
234
+ // per (path, mtime) — a dist file is immutable per build, so each is compressed once, not per request.
235
+ const gzMemo = new Map<string, { mtime: number; gz: Buffer }>()
236
+ function serveStatic(req: http.IncomingMessage, res: http.ServerResponse, distDir: string, urlPath: string) {
205
237
  const rel = normalize(decodeURIComponent(urlPath)).replace(/^(\.\.[/\\])+/, '')
206
238
  let file = join(distDir, rel)
207
239
  if (!file.startsWith(distDir)) file = join(distDir, 'index.html')
208
240
  if (urlPath === '/' || !existsSync(file)) file = join(distDir, 'index.html')
209
241
  if (!existsSync(file)) { res.writeHead(503); return res.end('dashboard build missing') }
210
- res.writeHead(200, { 'Content-Type': MIME[extname(file)] || 'application/octet-stream' })
211
- res.end(readFileSync(file))
242
+ const type = MIME[extname(file)] || 'application/octet-stream'
243
+ const raw = readFileSync(file)
244
+ if (wantsGzip(req) && COMPRESSIBLE.test(type)) {
245
+ const mtime = statSync(file).mtimeMs
246
+ let hit = gzMemo.get(file)
247
+ if (!hit || hit.mtime !== mtime) { hit = { mtime, gz: gzipSync(raw) }; gzMemo.set(file, hit) }
248
+ res.writeHead(200, { 'Content-Type': type, 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding' })
249
+ return res.end(hit.gz)
250
+ }
251
+ res.writeHead(200, { 'Content-Type': type })
252
+ res.end(raw)
212
253
  }
213
254
 
214
255
  function sendHtml(res: http.ServerResponse, status: number, html: string) {