spexcode 0.6.5 → 0.6.7

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 (56) hide show
  1. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.d.ts +2 -1
  2. package/node_modules/@spexcode/{spec-cli → session-core}/dist/delivery-queue.js +29 -2
  3. package/node_modules/@spexcode/session-core/dist/index.d.ts +5 -0
  4. package/node_modules/@spexcode/session-core/dist/index.js +5 -0
  5. package/node_modules/@spexcode/session-core/dist/internal.d.ts +3 -0
  6. package/node_modules/@spexcode/session-core/dist/internal.js +3 -0
  7. package/node_modules/@spexcode/session-core/dist/message.d.ts +22 -0
  8. package/node_modules/@spexcode/session-core/dist/message.js +53 -0
  9. package/node_modules/@spexcode/session-core/dist/record-lock.d.ts +7 -0
  10. package/node_modules/@spexcode/session-core/dist/record-lock.js +152 -0
  11. package/node_modules/@spexcode/session-core/dist/runtime-session.d.ts +62 -0
  12. package/node_modules/@spexcode/session-core/dist/runtime-session.js +326 -0
  13. package/node_modules/@spexcode/session-core/dist/session-timeline.d.ts +47 -0
  14. package/node_modules/@spexcode/session-core/dist/session-timeline.js +216 -0
  15. package/node_modules/@spexcode/session-core/package.json +33 -0
  16. package/node_modules/@spexcode/spec-cli/bin/spex.mjs +2 -1
  17. package/node_modules/@spexcode/spec-cli/dist/claude-headless.d.ts +4 -1
  18. package/node_modules/@spexcode/spec-cli/dist/claude-headless.js +13 -4
  19. package/node_modules/@spexcode/spec-cli/dist/cli.js +72 -23
  20. package/node_modules/@spexcode/spec-cli/dist/client.d.ts +2 -1
  21. package/node_modules/@spexcode/spec-cli/dist/client.js +13 -8
  22. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.d.ts +5 -0
  23. package/node_modules/@spexcode/spec-cli/dist/codex-runtime-generations.js +112 -0
  24. package/node_modules/@spexcode/spec-cli/dist/doctor.js +7 -1
  25. package/node_modules/@spexcode/spec-cli/dist/gateway-hub.js +7 -5
  26. package/node_modules/@spexcode/spec-cli/dist/gateway.d.ts +1 -0
  27. package/node_modules/@spexcode/spec-cli/dist/gateway.js +44 -20
  28. package/node_modules/@spexcode/spec-cli/dist/graphCache.js +2 -1
  29. package/node_modules/@spexcode/spec-cli/dist/graphSnapshot.js +2 -1
  30. package/node_modules/@spexcode/spec-cli/dist/harness.d.ts +15 -2
  31. package/node_modules/@spexcode/spec-cli/dist/harness.js +174 -54
  32. package/node_modules/@spexcode/spec-cli/dist/help.d.ts +5 -0
  33. package/node_modules/@spexcode/spec-cli/dist/help.js +26 -6
  34. package/node_modules/@spexcode/spec-cli/dist/index.js +3 -3
  35. package/node_modules/@spexcode/spec-cli/dist/listen.d.ts +2 -1
  36. package/node_modules/@spexcode/spec-cli/dist/listen.js +10 -10
  37. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.d.ts +1 -0
  38. package/node_modules/@spexcode/spec-cli/dist/opencode-headless.js +7 -0
  39. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.d.ts +1 -0
  40. package/node_modules/@spexcode/spec-cli/dist/runtime-rotate.js +58 -0
  41. package/node_modules/@spexcode/spec-cli/dist/session-follow.js +1 -1
  42. package/node_modules/@spexcode/spec-cli/dist/session-timeline.d.ts +6 -46
  43. package/node_modules/@spexcode/spec-cli/dist/session-timeline.js +8 -221
  44. package/node_modules/@spexcode/spec-cli/dist/sessions.d.ts +25 -4
  45. package/node_modules/@spexcode/spec-cli/dist/sessions.js +822 -394
  46. package/node_modules/@spexcode/spec-cli/dist/supervise.js +3 -3
  47. package/node_modules/@spexcode/spec-cli/package.json +6 -4
  48. package/node_modules/@spexcode/spec-core/dist/git.d.ts +4 -0
  49. package/node_modules/@spexcode/spec-core/dist/git.js +32 -0
  50. package/node_modules/@spexcode/spec-core/dist/layout.d.ts +4 -0
  51. package/node_modules/@spexcode/spec-core/package.json +1 -1
  52. package/node_modules/@spexcode/spec-eval/package.json +2 -2
  53. package/node_modules/@spexcode/spec-forge/package.json +2 -2
  54. package/package.json +3 -3
  55. /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.d.ts +0 -0
  56. /package/node_modules/@spexcode/{spec-cli → session-core}/dist/session-cursors.js +0 -0
@@ -6,6 +6,34 @@ import { installEvalHost } from './eval-host.js';
6
6
  installEvalHost();
7
7
  import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
8
8
  const cmd = process.argv[2];
9
+ function levenshtein(a, b) {
10
+ const row = Array.from({ length: b.length + 1 }, (_, index) => index);
11
+ for (let i = 1; i <= a.length; i++) {
12
+ let previous = row[0];
13
+ row[0] = i;
14
+ for (let j = 1; j <= b.length; j++) {
15
+ const current = row[j];
16
+ row[j] = Math.min(row[j] + 1, row[j - 1] + 1, previous + (a[i - 1] === b[j - 1] ? 0 : 1));
17
+ previous = current;
18
+ }
19
+ }
20
+ return row[b.length];
21
+ }
22
+ function nearestPublicCommand(input, commands) {
23
+ const query = input.toLowerCase();
24
+ if (!/^[a-z0-9-]+$/.test(query))
25
+ return null;
26
+ const similarity = (a, b) => 1 - levenshtein(a, b) / Math.max(a.length, b.length);
27
+ const words = (text) => text.toLowerCase().match(/[a-z0-9-]+/g) ?? [];
28
+ let best = null;
29
+ for (const command of commands) {
30
+ const [headline = '', ...detail] = command.text.split('\n');
31
+ const score = Math.max(similarity(query, command.name), ...words(headline).map((word) => similarity(query, word)), ...words(detail.join('\n')).map((word) => similarity(query, word) * 0.8));
32
+ if (!best || score > best.score)
33
+ best = { name: command.name, score };
34
+ }
35
+ return best && best.score >= 0.8 ? best.name : null;
36
+ }
9
37
  if (cmd === '--version' || cmd === '-v') {
10
38
  const manifest = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
11
39
  console.log(manifest.version);
@@ -31,14 +59,15 @@ async function assertDaemonDependencies(command) {
31
59
  process.exit(1);
32
60
  }
33
61
  // Registered before any await so a fatal top-level error lands here. Errors we OWN — BackendError, the
34
- // loud malformed-config ConfigError, the --api/--port UsageError, the write-guard GuardError are
62
+ // loud malformed-config ConfigError, the --api/--port UsageError, the write-guard GuardError, and a
63
+ // GitWorkspaceError that teaches a fresh directory how to continue — are
35
64
  // matched BY NAME (to avoid importing them) and rendered as a one-line `spex: <message>` (a user's
36
65
  // config typo or a refused cross-project write must read as their situation, not a SpexCode stack dump);
37
66
  // anything else prints in full so a real bug keeps its trace. A synchronous throw inside an awaited call
38
67
  // (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
39
68
  // paths route through the same printer.
40
69
  function fatal(e) {
41
- if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError', 'DashboardAssetError'].includes(e.name))
70
+ if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError', 'DashboardAssetError', 'GitWorkspaceError'].includes(e.name))
42
71
  console.error(`spex: ${e.message}`);
43
72
  else
44
73
  console.error(e);
@@ -68,6 +97,13 @@ function flushExit(code = 0) {
68
97
  const has = (name) => process.argv.includes(`--${name}`);
69
98
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
70
99
  const VALUE_FLAGS = new Set(['--status', '--as', '--interval', '--propose', '--note', '--node', '--prompt', '--prompt-file', '--timeout', '--reason', '--out', '--content-dir', '--password', '--tls-cert', '--tls-key', '--harness', '--launcher', '--harness-session', '--port', '--api', '--api-port', '--host', '--preset', '--limit', '--session', '--depth', '--focus', '--keys', '--ssh', '--allow-stop', '--allow-resume', '--ttl-ms', '--wait-ms', '--adapter', '--thread', '--tmux', '--worktree', '--branch', '--to', '--name', '--base', '--path', '--owner', '--details', '--variant', '--cli', '--count', '--ids']);
100
+ const EXPLICIT_BACKEND_ROUTE_FLAGS = ['api', 'port', 'password', 'insecure'];
101
+ const EXPLICIT_BACKEND_VALUE_FLAGS = EXPLICIT_BACKEND_ROUTE_FLAGS
102
+ .filter((name) => VALUE_FLAGS.has(`--${name}`))
103
+ .map((name) => `--${name}`);
104
+ const EXPLICIT_BACKEND_BARE_FLAGS = EXPLICIT_BACKEND_ROUTE_FLAGS
105
+ .filter((name) => !VALUE_FLAGS.has(`--${name}`))
106
+ .map((name) => `--${name}`);
71
107
  function positionals(from) {
72
108
  const out = [];
73
109
  for (let i = from; i < process.argv.length; i++) {
@@ -81,7 +117,7 @@ function positionals(from) {
81
117
  }
82
118
  return out;
83
119
  }
84
- function rejectUnknownFlags(command, from, allowed, attached = []) {
120
+ function rejectFlags(command, from, allowed, attached = []) {
85
121
  const known = new Set(allowed.map((name) => `--${name}`));
86
122
  for (let i = from; i < process.argv.length; i++) {
87
123
  const token = process.argv[i];
@@ -97,6 +133,12 @@ function rejectUnknownFlags(command, from, allowed, attached = []) {
97
133
  i++;
98
134
  }
99
135
  }
136
+ function rejectUnknownFlags(command, from, allowed, attached = []) {
137
+ rejectFlags(command, from, allowed, attached);
138
+ }
139
+ function rejectUnknownBackendFlags(command, from, allowed, attached = []) {
140
+ rejectFlags(command, from, [...allowed, ...EXPLICIT_BACKEND_ROUTE_FLAGS], attached);
141
+ }
100
142
  // `--children` deliberately has an optional value only in its attached form. A separated following token
101
143
  // remains a normal ls selector, so the long-standing `ls --children <child-SEL>` grammar keeps its meaning.
102
144
  function childrenScopeOption() {
@@ -125,8 +167,8 @@ function sessionSendUsage(detail, keys = false) {
125
167
  process.exit(2);
126
168
  }
127
169
  function parseSessionSendArgs(args) {
128
- const valueFlags = new Set(['--api', '--port', '--keys', '--password', '--ssh']);
129
- const bareFlags = new Set(['--insecure']);
170
+ const valueFlags = new Set([...EXPLICIT_BACKEND_VALUE_FLAGS, '--keys', '--ssh']);
171
+ const bareFlags = new Set(EXPLICIT_BACKEND_BARE_FLAGS);
130
172
  const values = new Map();
131
173
  const positionals = [];
132
174
  let endOfOptions = false;
@@ -210,8 +252,8 @@ function sessionTargetUsage(verb, detail) {
210
252
  }
211
253
  function parseSessionTargetArgs(verb, args) {
212
254
  const values = new Map();
213
- const valueFlags = new Set(['--api', '--port', '--password', '--ssh']);
214
- const bareFlags = new Set(verb === 'show' ? ['--capture', '--json', '--insecure'] : ['--insecure']);
255
+ const valueFlags = new Set([...EXPLICIT_BACKEND_VALUE_FLAGS, '--ssh']);
256
+ const bareFlags = new Set([...EXPLICIT_BACKEND_BARE_FLAGS, ...(verb === 'show' ? ['--capture', '--json'] : [])]);
215
257
  const positionals = [];
216
258
  for (let i = 0; i < args.length; i++) {
217
259
  const token = args[i];
@@ -253,6 +295,7 @@ const SIGNPOSTS = {
253
295
  blob: 'spex evidence put|get',
254
296
  issues: 'spex issue — ls (was: bare issues) · show · open · reply · close · promote; on|off|status → the `issues.enabled` key in spexcode.json; `issues nudge` → spex internal nudge',
255
297
  forge: 'spex issue links [--pending] [--store <host>] (--host is now --store)',
298
+ runtime: 'spex doctor repair app-server',
256
299
  new: 'spex session new',
257
300
  ls: 'spex session ls',
258
301
  watch: 'spex session watch',
@@ -395,7 +438,7 @@ async function stateKit() {
395
438
  // reach here — the signpost table above already exited.)
396
439
  if (cmd && cmd !== 'help' && (has('help') || process.argv.includes('-h'))) {
397
440
  const { commandHelp, overviewHelp } = await import('./help.js');
398
- console.log(commandHelp(cmd, cmd === 'session' ? process.argv[3] : undefined) ?? overviewHelp());
441
+ console.log(commandHelp(cmd, cmd === 'session' || cmd === 'doctor' ? process.argv[3] : undefined) ?? overviewHelp());
399
442
  process.exit(0);
400
443
  }
401
444
  if (cmd === 'serve') {
@@ -955,7 +998,7 @@ else if (cmd === 'session') {
955
998
  }
956
999
  const newPositionals = positionals(4);
957
1000
  const peerAnchor = parseSessionPeerAnchor('new', newPositionals);
958
- rejectUnknownFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'name', 'base', 'api', 'port', 'ssh']);
1001
+ rejectUnknownBackendFlags('spex session new', 4, ['prompt', 'prompt-file', 'launcher', 'name', 'base', 'ssh']);
959
1002
  if (peerAnchor && newPositionals.length > 2)
960
1003
  sessionPeerAnchorUsage('new', '--ssh accepts one full-id anchor and one inline prompt at most');
961
1004
  const { createSession, ownSessionId, withPeerSenderHint } = await import('./sessions.js');
@@ -1023,7 +1066,7 @@ else if (cmd === 'session') {
1023
1066
  // The backend's default projection excludes cold archives. --all and an explicit selector request the
1024
1067
  // history projection so an operator can still inspect or unarchive one deliberately.
1025
1068
  const selectors = positionals(4);
1026
- rejectUnknownFlags('spex session ls', 4, ['status', 'all', 'json', 'api', 'port', 'ssh', 'children'], ['children']);
1069
+ rejectUnknownBackendFlags('spex session ls', 4, ['status', 'all', 'json', 'ssh', 'children'], ['children']);
1027
1070
  const peerAnchor = parseSessionPeerAnchor('ls', selectors);
1028
1071
  const children = childrenScopeOption();
1029
1072
  if (peerAnchor && selectors.length !== 1)
@@ -1089,7 +1132,7 @@ else if (cmd === 'session') {
1089
1132
  }
1090
1133
  }
1091
1134
  else if (sub === 'resources') {
1092
- rejectUnknownFlags('spex session resources', 4, ['json', 'api', 'port']);
1135
+ rejectUnknownBackendFlags('spex session resources', 4, ['json']);
1093
1136
  const { clientResources } = await import('./client.js');
1094
1137
  const report = await clientResources();
1095
1138
  if (has('json'))
@@ -1368,7 +1411,7 @@ else if (cmd === 'session') {
1368
1411
  console.log(`closed ${full}`);
1369
1412
  }
1370
1413
  else if (sub === 'quarantine') {
1371
- rejectUnknownFlags('spex session quarantine', 4, ['adapter', 'thread', 'tmux', 'worktree', 'branch', 'restore', 'api', 'port']);
1414
+ rejectUnknownBackendFlags('spex session quarantine', 4, ['adapter', 'thread', 'tmux', 'worktree', 'branch', 'restore']);
1372
1415
  if (!id) {
1373
1416
  console.error('usage: spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <session-id> --worktree <absent-path> --branch <absent-branch> (--thread is adapter-native; omit it for Claude)');
1374
1417
  process.exit(2);
@@ -1390,7 +1433,7 @@ else if (cmd === 'session') {
1390
1433
  }
1391
1434
  }
1392
1435
  else if (sub === 'reparent') {
1393
- rejectUnknownFlags('spex session reparent', 4, ['to', 'api', 'port']);
1436
+ rejectUnknownBackendFlags('spex session reparent', 4, ['to']);
1394
1437
  const children = positionals(4);
1395
1438
  const to = flag('to');
1396
1439
  if (!children.length || !to) {
@@ -1425,8 +1468,8 @@ else if (cmd === 'session') {
1425
1468
  console.error(`spex session send --keys: nothing delivered to ${full} (offline, unknown session, or no valid key token)`);
1426
1469
  process.exit(1);
1427
1470
  }
1428
- // The backend decides send success at the timeline append. A dead adapter poke only delays context
1429
- // injection, while a refused record write prints the reason and exits non-zero.
1471
+ // A send is accepted at its timeline append, except a proven-unreachable transport attached to a live
1472
+ // registered agent: that stranded combination refuses before it can add unclaimable queue debt.
1430
1473
  // BIDIRECTIONAL: stamp the SENDER (this send process's OWN session — the only process that knows it, via
1431
1474
  // ownSessionId from CLAUDE_CODE_SESSION_ID) + a one-line reply hint into the delivered
1432
1475
  // message, so the recipient can reply over the SAME send. The sender's row (hence its display label) is
@@ -1455,8 +1498,12 @@ else if (cmd === 'session') {
1455
1498
  const r = sendArgs.sshAddress
1456
1499
  ? await c.clientSendThroughPeer(sendArgs.sshAddress, full, text, from)
1457
1500
  : await c.clientSend(full, text, from);
1458
- console.log(r.ok ? 'sent' : `dispatch failed: ${r.error}`);
1459
- process.exit(r.ok ? 0 : 1);
1501
+ if (r.ok) {
1502
+ console.log('sent');
1503
+ process.exit(0);
1504
+ }
1505
+ console.error(`dispatch failed: ${r.error}`);
1506
+ process.exit(1);
1460
1507
  }
1461
1508
  else if (sub === 'show') {
1462
1509
  // the session RECORD as one per-id read (status · node · branch · launcher · the full originating
@@ -1637,11 +1684,11 @@ else if (cmd === 'internal') {
1637
1684
  }
1638
1685
  else if (sub === 'codex-launch') {
1639
1686
  // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
1640
- // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
1641
- // SPEXCODE_SESSION_ID), fire the launch prompt as the FIRST turn materializing the rollout and print the
1642
- // thread id. The launch script then `resume`s it in the visible TUI.
1687
+ // (codex loads that worktree's config/hooks/AGENTS.md), fire the launch prompt as the FIRST turn
1688
+ // materializing the rollout then stage the id + exact-payload proof for the session lifecycle owner and
1689
+ // print the thread id. The launch script then `resume`s it in the visible TUI.
1643
1690
  const { codexStartThread, codexTurn, waitForCodexRollout, codexBinary, codexSupportsBypassHookTrust, codexLauncherThreadPolicy } = await import('./harness.js');
1644
- const { markHarnessSessionId } = await import('./sessions.js');
1691
+ const { stageHarnessLaunchProof } = await import('./sessions.js');
1645
1692
  const sock = process.argv[4], cwd = process.argv[5];
1646
1693
  const prompt = process.argv.slice(6).join(' ');
1647
1694
  if (!sock || !cwd) {
@@ -1680,7 +1727,7 @@ else if (cmd === 'internal') {
1680
1727
  }
1681
1728
  const sid = process.env.SPEXCODE_SESSION_ID;
1682
1729
  if (sid)
1683
- markHarnessSessionId(sid, r.threadId);
1730
+ stageHarnessLaunchProof(sid, r.threadId, prompt);
1684
1731
  console.log(r.threadId);
1685
1732
  }
1686
1733
  else if (sub === 'opencode-capture') {
@@ -1854,6 +1901,8 @@ else if (cmd === 'internal') {
1854
1901
  }
1855
1902
  }
1856
1903
  else {
1857
- console.error(`spex: unknown command '${cmd}' (try: spex help)`);
1904
+ const { publicCommands } = await import('./help.js');
1905
+ const suggestion = nearestPublicCommand(cmd, publicCommands());
1906
+ console.error(`spex: unknown command '${cmd}'${suggestion ? ` — try: spex ${suggestion}` : ''} (try: spex help)`);
1858
1907
  process.exit(2);
1859
1908
  }
@@ -1,4 +1,5 @@
1
1
  import { type CockpitReview } from './cockpit.js';
2
+ import type { SessionEvalRevision } from '@spexcode/spec-eval/sessioneval';
2
3
  import { type Session, type SessionClosure, type Resolved, type DispatchResult } from './sessions.js';
3
4
  export declare class BackendError extends Error {
4
5
  readonly status?: number | undefined;
@@ -48,7 +49,7 @@ type SessionEvalPage = {
48
49
  unknown: number;
49
50
  revision: string;
50
51
  summary?: any;
51
- evalRevision?: any;
52
+ evalRevision?: SessionEvalRevision;
52
53
  };
53
54
  export type EvalsResult = {
54
55
  ok: true;
@@ -259,7 +259,7 @@ export async function clientCapture(id) {
259
259
  return { ok: false, status: r.status, reason: (await r.text().catch(() => '')) || `status ${r.status}` };
260
260
  }
261
261
  // POST /api/sessions/:id/input {kind:"text"} appends the prompt to the durable timeline, then best-effort
262
- // pokes the resolved adapter. HTTP failure means the append itself was refused.
262
+ // pokes the resolved adapter. HTTP failure means the append was refused, including a proven stranded transport.
263
263
  export async function clientSend(id, text, from) {
264
264
  await guarded('session send');
265
265
  // `from` = the sending agent's own session id; the recipient's log records the sender ([[session-timeline]]) only when
@@ -331,30 +331,35 @@ export async function clientEvalExport(id) {
331
331
  return { ok: true, body: await r.text() };
332
332
  return { ok: false, status: r.status };
333
333
  }
334
+ const evalRevisionKey = (revision) => JSON.stringify([revision.epoch, revision.generation, revision.content]);
335
+ const formatEvalRevision = (revision) => revision ? `${revision.epoch}@${revision.generation} (${revision.content})` : 'missing';
334
336
  export async function clientEvals(id) {
335
337
  const q = encodeURIComponent(`is:eval scope:${id}`);
338
+ const drifts = [];
336
339
  for (let attempt = 0; attempt < 2; attempt++) {
337
340
  const items = [];
338
341
  let first = null;
339
- let changed = false;
342
+ let snapshot = null;
340
343
  for (let page = 1;; page++) {
341
344
  const r = await apiFetch(`/api/evals?q=${q}&page=${page}`);
342
345
  if (!r.ok)
343
346
  return { ok: false, status: r.status };
344
347
  const current = await r.json();
345
348
  first ??= current;
346
- if (current.revision !== first.revision) {
347
- changed = true;
349
+ if (current.pageCount > 1 && !current.evalRevision) {
350
+ throw new BackendError(`session eval page ${page}/${current.pageCount} for ${id} has no evalRevision; cannot assemble a consistent snapshot`);
351
+ }
352
+ snapshot ??= current.evalRevision ?? null;
353
+ if (current.evalRevision && snapshot && evalRevisionKey(current.evalRevision) !== evalRevisionKey(snapshot)) {
354
+ drifts.push(`attempt ${attempt + 1}: ${formatEvalRevision(snapshot)} -> ${formatEvalRevision(current.evalRevision)} at page ${page}`);
348
355
  break;
349
356
  }
350
357
  items.push(...current.items);
351
358
  if (page >= current.pageCount)
352
- break;
359
+ return { ok: true, model: { ...first, id, items } };
353
360
  }
354
- if (!changed)
355
- return { ok: true, model: { ...first, id, items } };
356
361
  }
357
- throw new BackendError(`session eval pages changed while fetching ${id}; retry the command`);
362
+ throw new BackendError(`session eval snapshot changed during both fetch attempts for ${id} (${drifts.join('; ')}); retry the command`);
358
363
  }
359
364
  // POST /api/sessions/:id/merge — a human merge intent dispatched to the session's own agent.
360
365
  export async function clientMerge(id) {
@@ -31,7 +31,12 @@ export type CodexGenerationLedger = Readonly<{
31
31
  export declare function codexGenerationSocketPath(root: string, generationId?: string): string;
32
32
  export declare function legacyCodexGenerationEndpoint(root: string): CodexGenerationEndpoint;
33
33
  export declare function readCodexGenerationLedger(root: string): CodexGenerationLedger;
34
+ export type CodexGenerationRotation = Readonly<{
35
+ previous: CodexGenerationEndpoint;
36
+ current: CodexGenerationEndpoint;
37
+ }>;
34
38
  export declare function ensureCodexCurrentGeneration(root: string, start: (endpoint: CodexGenerationEndpoint) => Promise<void>): Promise<CodexGenerationEndpoint>;
39
+ export declare function rotateCodexCurrentGeneration(root: string, start: (endpoint: CodexGenerationEndpoint) => Promise<void>): Promise<CodexGenerationRotation>;
35
40
  export declare function bindCodexGeneration(root: string, sessionId: string, threadId: string, generationId: string | null): void;
36
41
  export declare function prepareCodexGenerationRegistration(root: string, sessionId: string, threadId: string, generationId: string): void;
37
42
  export declare function commitCodexGenerationRegistration(root: string, sessionId: string, threadId: string, generationId: string): void;
@@ -497,6 +497,118 @@ export async function ensureCodexCurrentGeneration(root, start) {
497
497
  return publishPendingGeneration(root, action.endpoint);
498
498
  }
499
499
  }
500
+ // An operator can move NEW traffic off a current root that is still exactly identifiable but unhealthy. This is
501
+ // deliberately not a restart: bindings remain on the old endpoint, which keeps serving them as draining until
502
+ // its ordinary zero-reference reclamation proof succeeds.
503
+ async function publishRotatedGeneration(root, endpoint) {
504
+ return withLedgerLock(root, async () => {
505
+ let previous = readCodexGenerationLedger(root);
506
+ if (previous.pending !== endpoint.id || !endpointIdentity(endpoint))
507
+ throw new Error('Codex generation rotation CAS lost or candidate identity changed before publication');
508
+ const previousId = previous.current;
509
+ const old = previousId ? previous.generations[previousId] : null;
510
+ if (!previousId || !old || old.state !== 'current')
511
+ throw new Error('canonical Codex generation changed during rotation; retry');
512
+ if (!endpointIdentity(old.endpoint)) {
513
+ // A root proven gone during the finite start window is safe to retire. Ambiguity remains a refusal: a
514
+ // ready candidate stays durably pending for a later explicit retry, rather than guessing at ownership.
515
+ const retired = retireGoneGenerationLocked(root, previous, previousId);
516
+ if (!retired)
517
+ throw new Error('canonical Codex generation became unproven during rotation; refusing to switch traffic');
518
+ previous = retired;
519
+ }
520
+ const generations = {
521
+ ...previous.generations,
522
+ [endpoint.id]: { state: 'current', endpoint },
523
+ };
524
+ if (previous.current)
525
+ generations[previous.current] = { state: 'draining', endpoint: old.endpoint };
526
+ writeLedger(root, previous, { current: endpoint.id, pending: null, generations, bindings: previous.bindings });
527
+ return { previous: old.endpoint, current: endpoint };
528
+ });
529
+ }
530
+ export async function rotateCodexCurrentGeneration(root, start) {
531
+ const deadline = Date.now() + 30_000;
532
+ for (;;) {
533
+ const action = await withLedgerLock(root, async () => {
534
+ let previous = readCodexGenerationLedger(root);
535
+ if (previous.revision === 0 && !existsSync(ledgerPath(root))) {
536
+ const bootstrapped = bootstrapLedger(root);
537
+ previous = writeLedger(root, previous, bootstrapped);
538
+ }
539
+ const current = previous.current ? previous.generations[previous.current] : null;
540
+ if (!current || current.state !== 'current')
541
+ throw new Error('there is no proven canonical app-server generation to switch; launch a Codex session first');
542
+ if (!endpointIdentity(current.endpoint)) {
543
+ if (goneGeneration(current.endpoint))
544
+ throw new Error('canonical Codex generation is already dead; a normal Codex launch will replace it');
545
+ throw new Error('canonical app-server generation is unproven; refusing to switch traffic');
546
+ }
547
+ if (previous.pending) {
548
+ const pending = previous.generations[previous.pending];
549
+ if (!pending || pending.state !== 'starting')
550
+ throw new Error('Codex generation ledger pending rotation is malformed');
551
+ // A prior coordinator may have completed the detached spawn but crashed before the pointer CAS.
552
+ if (endpointIdentity(pending.endpoint))
553
+ return { kind: 'publish', endpoint: pending.endpoint };
554
+ if (pending.reservation && processStartToken(pending.reservation.pid) === pending.reservation.startToken)
555
+ return { kind: 'wait' };
556
+ const generations = { ...previous.generations };
557
+ delete generations[pending.endpoint.id];
558
+ writeLedger(root, previous, { current: previous.current, pending: null, generations, bindings: previous.bindings });
559
+ return { kind: 'retry' };
560
+ }
561
+ const endpoint = newEndpoint(root);
562
+ const startToken = processStartToken(process.pid);
563
+ if (!startToken)
564
+ throw new Error('cannot prove coordinator process identity for Codex generation rotation reservation');
565
+ mkdirSync(dirname(endpoint.pidFile), { recursive: true, mode: 0o700 });
566
+ writeLedger(root, previous, {
567
+ current: previous.current,
568
+ pending: endpoint.id,
569
+ generations: { ...previous.generations, [endpoint.id]: { state: 'starting', endpoint, reservation: { pid: process.pid, startToken } } },
570
+ bindings: previous.bindings,
571
+ });
572
+ return { kind: 'start', endpoint };
573
+ });
574
+ if (action.kind === 'publish')
575
+ return publishRotatedGeneration(root, action.endpoint);
576
+ if (action.kind === 'retry')
577
+ continue;
578
+ if (action.kind === 'wait') {
579
+ if (Date.now() >= deadline)
580
+ throw new Error('Codex generation rotation reservation owner is still live but did not publish; retry after it exits');
581
+ await sleep(50);
582
+ continue;
583
+ }
584
+ try {
585
+ await start(action.endpoint);
586
+ }
587
+ catch (error) {
588
+ await withLedgerLock(root, async () => {
589
+ const previous = readCodexGenerationLedger(root);
590
+ if (previous.pending !== action.endpoint.id || endpointIdentity(action.endpoint))
591
+ return;
592
+ const generations = { ...previous.generations };
593
+ delete generations[action.endpoint.id];
594
+ writeLedger(root, previous, { current: previous.current, pending: null, generations, bindings: previous.bindings });
595
+ });
596
+ throw error;
597
+ }
598
+ if (!await waitForEndpoint(action.endpoint)) {
599
+ await withLedgerLock(root, async () => {
600
+ const previous = readCodexGenerationLedger(root);
601
+ if (previous.pending !== action.endpoint.id || endpointIdentity(action.endpoint))
602
+ return;
603
+ const generations = { ...previous.generations };
604
+ delete generations[action.endpoint.id];
605
+ writeLedger(root, previous, { current: previous.current, pending: null, generations, bindings: previous.bindings });
606
+ });
607
+ throw new Error(`candidate Codex generation ${action.endpoint.id} did not prove a live detached endpoint; current pointer was not switched`);
608
+ }
609
+ return publishRotatedGeneration(root, action.endpoint);
610
+ }
611
+ }
500
612
  export function bindCodexGeneration(root, sessionId, threadId, generationId) {
501
613
  withLedgerLockSync(root, () => {
502
614
  const previous = readCodexGenerationLedger(root);
@@ -518,7 +518,9 @@ function usage() {
518
518
  console.error(`spex doctor — diagnose spec health and how the SpexCode workflow reaches your agent
519
519
  (bare) spec-health findings + delivery report: preconditions · git-hook floor · contract · hooks(+handlers) · backend · footprint
520
520
  --contract print the surface:system contract text (hand it to any agent)
521
- --conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)`);
521
+ --conflicts detect double-delivery — the same agent reached via loose native delivery AND a plugin bundle (exits non-zero on conflict)
522
+ repair app-server [--launcher <name>]
523
+ prove a fresh app-server, then switch new sessions to it without moving existing sessions`);
522
524
  return 0;
523
525
  }
524
526
  export async function runDoctor(args) {
@@ -530,6 +532,10 @@ export async function runDoctor(args) {
530
532
  return contract();
531
533
  if (args.includes('--conflicts'))
532
534
  return await conflicts();
535
+ if (args[0] === 'repair') {
536
+ const { runDoctorRepairAppServer } = await import('./runtime-rotate.js');
537
+ return await runDoctorRepairAppServer(args);
538
+ }
533
539
  switch (args[0]) {
534
540
  case undefined: return await doctor();
535
541
  case 'contract':
@@ -327,11 +327,13 @@ export function startHubGateway(opts) {
327
327
  socket.once('close', () => upstream.destroy());
328
328
  upstream.once('close', () => socket.destroy());
329
329
  });
330
- const onListen = () => {
331
- const scheme = secure ? 'https' : 'http';
332
- console.log(`[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${port} — /projects + /p/:projectId/*`);
333
- };
334
- listenOrExit(server, port, { host: opts.host, label: opts.label ?? 'hub gateway', cleanup: opts.onBindFail, onListen });
330
+ const scheme = secure ? 'https' : 'http';
331
+ listenOrExit(server, port, {
332
+ host: opts.host,
333
+ label: opts.label ?? 'hub gateway',
334
+ cleanup: opts.onBindFail,
335
+ ready: `[hub] multi-project gateway on ${scheme}://${opts.host ?? '0.0.0.0'}:${port} — /projects + /p/:projectId/*`,
336
+ });
335
337
  return server;
336
338
  }
337
339
  // replay an upgrade's headers with the Cookie header rewritten to exclude the gateway's own cookies.
@@ -22,6 +22,7 @@ export type GatewayOpts = {
22
22
  label?: string;
23
23
  onBindFail?: () => void;
24
24
  projectRoot?: string;
25
+ readyLines?: string[];
25
26
  };
26
27
  export declare function startGateway(opts: GatewayOpts): void;
27
28
  export declare function rawHeaders(req: http.IncomingMessage): string;
@@ -178,17 +178,15 @@ export function startGateway(opts) {
178
178
  // narrows the public gateway's reach. The gate note keys on LOOPBACK, not on host-being-explicit:
179
179
  // an ungated loopback bind is normal, an ungated wide bind is announced — never silent.
180
180
  const isLoopback = opts.host === '127.0.0.1' || opts.host === 'localhost' || opts.host === '::1';
181
- const onListen = () => {
182
- const scheme = secure ? 'https' : 'http';
183
- const label = opts.label ?? 'public mode';
184
- const gate = isLoopback ? '' : ` ${gated ? 'password-gated' : 'OPEN (no password)'}`;
185
- console.log(`[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`);
186
- if (!secure && !isLoopback && !opts.host)
187
- console.log('[gateway] (TLS off — --http)');
188
- };
181
+ const scheme = secure ? 'https' : 'http';
182
+ const label = opts.label ?? 'public mode';
183
+ const gate = isLoopback ? '' : ` — ${gated ? 'password-gated' : 'OPEN (no password)'}`;
184
+ const ready = [...(opts.readyLines ?? []), `[gateway] ${label} on ${scheme}://${isLoopback ? 'localhost' : (opts.host ?? '0.0.0.0')}:${opts.publicPort}${gate}, proxying /api to :${opts.upstreamPort}`];
185
+ if (!secure && !isLoopback && !opts.host)
186
+ ready.push('[gateway] (TLS off --http)');
189
187
  // a busy public port is a hard, loud, non-zero exit — the SAME contract as the supervisor's proxy
190
188
  // (see [[spec-cli]] / listen.ts), so `spex serve` and `spex serve ui` fail a port clash identically.
191
- listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, onListen });
189
+ listenOrExit(server, opts.publicPort, { host: opts.host, label: opts.label ?? 'gateway', cleanup: opts.onBindFail, ready });
192
190
  }
193
191
  // re-serialize an upgrade request's headers for replay against the upstream (exported for the host
194
192
  // gateway's per-project WS pipe, which replays the same way).
@@ -223,6 +221,16 @@ function doLogin(req, res, password, setCookie) {
223
221
  // and would fight Range requests.
224
222
  const COMPRESSIBLE = /^(text\/|application\/(json|javascript|xml)|image\/svg)/;
225
223
  const wantsGzip = (req) => /\bgzip\b/.test(String(req.headers['accept-encoding'] || ''));
224
+ // zlib's larger default table can be counterproductive for minified text: memLevel 5 both compresses it
225
+ // further and lowers each stream's working memory. One policy drives buffered and streamed gzip.
226
+ const GZIP_OPTIONS = { level: 9, memLevel: 5 };
227
+ function appendVary(current, token) {
228
+ const values = (Array.isArray(current) ? current : [current ?? ''])
229
+ .flatMap((value) => value.split(',')).map((value) => value.trim()).filter(Boolean);
230
+ if (!values.some((value) => value === '*' || value.toLowerCase() === token.toLowerCase()))
231
+ values.push(token);
232
+ return values.join(', ');
233
+ }
226
234
  // reverse-proxy an /api request to the loopback supervisor (which forwards to the live child) —
227
235
  // stream-gzipping compressible bodies (measured: the board JSON rides down at under a third).
228
236
  // `path` and `headers` optionally override routing inputs (the host gateway strips its /p/:projectId
@@ -322,16 +330,19 @@ export function proxyHttp(req, res, upstreamPort, path, headers = req.headers, u
322
330
  received.once('close', () => { if (!received.complete)
323
331
  failFromUpstream(); });
324
332
  const type = String(received.headers['content-type'] || '');
325
- const skip = !wantsGzip(req) || received.headers['content-encoding'] || !COMPRESSIBLE.test(type) || type.startsWith('text/event-stream');
326
- if (skip) {
327
- res.writeHead(received.statusCode || 502, received.headers);
333
+ const eligible = !received.headers['content-encoding'] && COMPRESSIBLE.test(type) && !type.startsWith('text/event-stream');
334
+ const responseHeaders = eligible
335
+ ? { ...received.headers, vary: appendVary(received.headers.vary, 'Accept-Encoding') }
336
+ : received.headers;
337
+ if (!eligible || !wantsGzip(req)) {
338
+ res.writeHead(received.statusCode || 502, responseHeaders);
328
339
  received.pipe(res);
329
340
  return;
330
341
  }
331
- const headers = { ...received.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' };
342
+ const headers = { ...responseHeaders, 'content-encoding': 'gzip' };
332
343
  delete headers['content-length']; // streamed; the encoded length isn't knowable up front
333
344
  res.writeHead(received.statusCode || 502, headers);
334
- transform = createGzip();
345
+ transform = createGzip(GZIP_OPTIONS);
335
346
  transform.once('error', failFromUpstream);
336
347
  received.pipe(transform).pipe(res);
337
348
  });
@@ -473,17 +484,21 @@ export function serveStatic(req, res, distDir, urlPath) {
473
484
  const type = MIME[extname(file)] || 'application/octet-stream';
474
485
  const cacheControl = /[\\/]assets[\\/]/.test(file) ? 'public, max-age=31536000, immutable' : 'no-cache';
475
486
  const raw = readFileSync(file);
476
- if (wantsGzip(req) && COMPRESSIBLE.test(type)) {
487
+ const compressible = COMPRESSIBLE.test(type);
488
+ const headers = { 'Content-Type': type, 'Cache-Control': cacheControl };
489
+ if (compressible)
490
+ headers.Vary = appendVary(undefined, 'Accept-Encoding');
491
+ if (wantsGzip(req) && compressible) {
477
492
  const mtime = statSync(file).mtimeMs;
478
493
  let hit = gzMemo.get(file);
479
494
  if (!hit || hit.mtime !== mtime) {
480
- hit = { mtime, gz: gzipSync(raw) };
495
+ hit = { mtime, gz: gzipSync(raw, GZIP_OPTIONS) };
481
496
  gzMemo.set(file, hit);
482
497
  }
483
- res.writeHead(200, { 'Content-Type': type, 'Content-Encoding': 'gzip', Vary: 'Accept-Encoding', 'Cache-Control': cacheControl });
498
+ res.writeHead(200, { ...headers, 'Content-Encoding': 'gzip' });
484
499
  return res.end(hit.gz);
485
500
  }
486
- res.writeHead(200, { 'Content-Type': type, 'Cache-Control': cacheControl });
501
+ res.writeHead(200, headers);
487
502
  res.end(raw);
488
503
  }
489
504
  function sendHtml(res, status, html) {
@@ -498,6 +513,15 @@ function sendHtml(res, status, html) {
498
513
  // installed user has no source tree for). See [[packaging]].
499
514
  export function serveDashboardLocal(opts) {
500
515
  const distDir = resolveDistDir();
501
- console.log(`[dashboard] serving ${distDir}, /api → backend :${opts.apiPort}`);
502
- startGateway({ host: opts.host ?? '127.0.0.1', publicPort: opts.port, upstreamPort: opts.apiPort, password: '', tls: null, distDir, label: 'dashboard', projectRoot: opts.projectRoot });
516
+ startGateway({
517
+ host: opts.host ?? '127.0.0.1',
518
+ publicPort: opts.port,
519
+ upstreamPort: opts.apiPort,
520
+ password: '',
521
+ tls: null,
522
+ distDir,
523
+ label: 'dashboard',
524
+ projectRoot: opts.projectRoot,
525
+ readyLines: [`[dashboard] serving ${distDir}, /api → backend :${opts.apiPort}`],
526
+ });
503
527
  }
@@ -3,7 +3,7 @@ import { readFileSync, readdirSync, statSync } from 'node:fs';
3
3
  import { isAbsolute, join, resolve } from 'node:path';
4
4
  import { rebasePublishedSessions } from '@spexcode/spec-core';
5
5
  import { buildBoard, spliceSessions } from './graphSnapshot.js';
6
- import { headSha, repoRoot, withGitAbortSignal } from '@spexcode/spec-core';
6
+ import { headSha, repoRoot, requireGitWorkspace, withGitAbortSignal } from '@spexcode/spec-core';
7
7
  import { listSessionIds, mainBranch, mainCheckout, readPublicRecordEntry, sessionArtifactPath, sessionRecordPath } from '@spexcode/spec-core';
8
8
  import { boardThreads } from './issues.js';
9
9
  import { resolveForgeHost } from '@spexcode/spec-forge/drivers';
@@ -162,6 +162,7 @@ function sessionInputRevision() {
162
162
  }
163
163
  function boardInputRevision(board) {
164
164
  const root = repoRoot();
165
+ requireGitWorkspace(root);
165
166
  const session = sessionInputRevision();
166
167
  // Durable active records are the current root set; a cached ordinary row may be stale and must not replace
167
168
  // them. The one projection-only addition is explicit: listSessions republishes an archived-runtime hazard
@@ -1,4 +1,4 @@
1
- import { loadSpecs } from '@spexcode/spec-core';
1
+ import { loadSpecs, requireGitWorkspace } from '@spexcode/spec-core';
2
2
  import { resolveLayout } from '@spexcode/spec-core';
3
3
  import { listSessions } from './sessions.js';
4
4
  import { driftIndex, historyIndex, repoRoot } from '@spexcode/spec-core';
@@ -12,6 +12,7 @@ import { sessionEvalProjections } from '@spexcode/spec-eval/sessioneval';
12
12
  // The application adapter is the sole reader of runtime/forge state. graph.ts only receives this result.
13
13
  export async function boardSnapshot() {
14
14
  const root = repoRoot();
15
+ requireGitWorkspace(root);
15
16
  const [specs, sessions] = await Promise.all([loadSpecs(), listSessions()]);
16
17
  const layout = await resolveLayout({ activeSessionIds: sessions.map((session) => session.id) });
17
18
  const nodeIds = [...new Set([