@spexcode/spec-cli 0.6.7 → 0.7.0-next.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 (97) hide show
  1. package/bin/spex.mjs +175 -21
  2. package/dist/cli.js +121 -59
  3. package/dist/client.d.ts +1 -3
  4. package/dist/client.js +49 -30
  5. package/dist/codex-runtime-generations.d.ts +11 -0
  6. package/dist/codex-runtime-generations.js +46 -9
  7. package/dist/delivery-lock.d.ts +2 -0
  8. package/dist/delivery-lock.js +58 -0
  9. package/dist/doctor.js +53 -11
  10. package/dist/file-write.js +14 -2
  11. package/dist/gateway-hub.js +2 -1
  12. package/dist/gateway.js +6 -3
  13. package/dist/graphCache.js +32 -2
  14. package/dist/graphSnapshot.js +57 -2
  15. package/dist/graphStream.d.ts +2 -0
  16. package/dist/graphStream.js +83 -3
  17. package/dist/guide.js +29 -18
  18. package/dist/harness-select.js +16 -3
  19. package/dist/harness.d.ts +24 -6
  20. package/dist/harness.js +510 -72
  21. package/dist/help.js +11 -8
  22. package/dist/hook-prompts.js +8 -0
  23. package/dist/host-resources.js +29 -8
  24. package/dist/host.d.ts +7 -0
  25. package/dist/host.js +98 -1
  26. package/dist/index.js +294 -36
  27. package/dist/init.js +1 -1
  28. package/dist/lint.js +70 -35
  29. package/dist/listen.d.ts +3 -2
  30. package/dist/listen.js +14 -2
  31. package/dist/machine-peer.d.ts +2 -1
  32. package/dist/machine-peer.js +46 -15
  33. package/dist/materialize.d.ts +2 -2
  34. package/dist/materialize.js +176 -35
  35. package/dist/opencode.js +10 -2
  36. package/dist/pi-harness.js +18 -5
  37. package/dist/pi-headless.d.ts +2 -0
  38. package/dist/pi-headless.js +27 -0
  39. package/dist/pty-bridge.js +14 -14
  40. package/dist/reviews.js +12 -7
  41. package/dist/runtime-ownership.d.ts +11 -0
  42. package/dist/runtime-ownership.js +79 -1
  43. package/dist/session-application.d.ts +23 -0
  44. package/dist/session-application.js +189 -0
  45. package/dist/session-declarations.js +13 -1
  46. package/dist/session-files.d.ts +6 -0
  47. package/dist/session-files.js +13 -1
  48. package/dist/session-follow.js +39 -22
  49. package/dist/session-record-lock.d.ts +3 -0
  50. package/dist/session-record-lock.js +94 -0
  51. package/dist/session-runtime-adapter.d.ts +44 -0
  52. package/dist/session-runtime-adapter.js +37 -0
  53. package/dist/session-timeline.d.ts +25 -2
  54. package/dist/session-timeline.js +68 -11
  55. package/dist/session-transcript.d.ts +57 -0
  56. package/dist/session-transcript.js +137 -0
  57. package/dist/session-web.js +4 -4
  58. package/dist/sessions.d.ts +112 -15
  59. package/dist/sessions.js +1553 -764
  60. package/dist/shim-runtime.js +14 -0
  61. package/dist/source-list.d.ts +13 -0
  62. package/dist/source-list.js +99 -0
  63. package/dist/source-read.d.ts +16 -0
  64. package/dist/source-read.js +84 -0
  65. package/dist/spec-attachments.d.ts +7 -0
  66. package/dist/spec-attachments.js +89 -0
  67. package/dist/spec-body-edit.d.ts +23 -0
  68. package/dist/spec-body-edit.js +138 -0
  69. package/dist/supervise.js +15 -6
  70. package/hooks/dispatch.sh +19 -31
  71. package/hooks/harness.sh +6 -6
  72. package/package.json +8 -6
  73. package/templates/hooks/post-checkout +4 -2
  74. package/templates/hooks/post-merge +2 -1
  75. package/templates/hooks/pre-commit +5 -3
  76. package/templates/hooks/reference-transaction +5 -3
  77. package/templates/spec/project/.plugins/commands/spec.md +2 -7
  78. package/templates/spec/project/.plugins/core/idle/idle.sh +4 -10
  79. package/templates/spec/project/.plugins/core/idle/spec.md +1 -1
  80. package/templates/spec/project/.plugins/core/mark-active/mark-active.sh +22 -24
  81. package/templates/spec/project/.plugins/core/mark-active/spec.md +10 -2
  82. package/templates/spec/project/.plugins/core/session-fail/fail.sh +8 -7
  83. package/templates/spec/project/.plugins/core/session-fail/spec.md +3 -1
  84. package/templates/spec/project/.plugins/core/session-listen/session-listen.sh +133 -0
  85. package/templates/spec/project/.plugins/core/session-listen/spec.md +36 -0
  86. package/templates/spec/project/.plugins/core/spec.md +2 -0
  87. package/templates/spec/project/.plugins/core/stop-gate/spec.md +1 -1
  88. package/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +17 -20
  89. package/templates/spec/project/.plugins/skills/merge/spec.md +33 -0
  90. package/templates/spec/project/.plugins/skills/spec.md +2 -6
  91. package/templates/spec/project/.plugins/spec.md +7 -0
  92. package/dist/execution-trace.d.ts +0 -26
  93. package/dist/execution-trace.js +0 -475
  94. package/dist/session-execution.d.ts +0 -10
  95. package/dist/session-execution.js +0 -70
  96. package/hooks/compat/mark-active-0.5.2-eef1.fixture +0 -53
  97. package/hooks/compat/mark-active-sed-v0.fixture +0 -46
package/bin/spex.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // @@@ spex launcher ([[release-launcher]]) - package installs execute the package's compiled CLI directly;
3
3
  // tsx remains a development tool and never enters an adopter's runtime closure.
4
4
  import { spawn, spawnSync } from 'node:child_process'
5
- import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
5
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync } from 'node:fs'
6
+ import { createHash } from 'node:crypto'
6
7
  import { fileURLToPath } from 'node:url'
7
8
  import { dirname, join } from 'node:path'
8
9
 
@@ -16,7 +17,19 @@ const workspace = join(pkg, '..')
16
17
  // carry unresolved conflict markers, so hooks keep the retryable exit-75 contract during a merge.
17
18
  const sourceRoot = join(pkg, 'src')
18
19
  if (existsSync(sourceRoot)) {
19
- const srcRoots = [sourceRoot, join(pkg, '..', 'packages', 'spec-core', 'src'), join(pkg, '..', 'packages', 'session-core', 'src'), join(pkg, '..', 'spec-eval', 'src'), join(pkg, '..', 'spec-forge', 'src')]
20
+ const buildTargets = [
21
+ ['packages/session-protocol', 'dist/index.js'],
22
+ ['packages/session-topology', 'dist/index.js'],
23
+ ['packages/session-runtime', 'dist/index.js'],
24
+ ['packages/session-events', 'dist/index.js'],
25
+ ['packages/session-application', 'dist/index.js'],
26
+ ['packages/session-selflaunch', 'dist/index.js'],
27
+ ['packages/spec-core', 'dist/index.js'],
28
+ ['spec-eval', 'dist/index.js'],
29
+ ['spec-forge', 'dist/index.js'],
30
+ ['spec-cli', 'dist/cli.js'],
31
+ ]
32
+ const srcRoots = buildTargets.map(([packagePath]) => join(pkg, '..', packagePath, 'src'))
20
33
  const conflicted = srcRoots.flatMap((root) => {
21
34
  if (!existsSync(root)) return []
22
35
  return readdirSync(root, { recursive: true })
@@ -34,33 +47,174 @@ if (existsSync(sourceRoot)) {
34
47
  }
35
48
 
36
49
  // @@@ source-workspace build ([[release-launcher]]) - the git hooks invoke this launcher straight from a
37
- // checkout, where dist is intentionally untracked. Rebuild the complete runtime closure before linting
38
- // candidate source; an installed package has no src tree and stays a pure Node -> dist execution path.
50
+ // checkout, where dist is intentionally untracked. Import main's fresh closure and rebuild only changed
51
+ // packages plus dependents before linting candidate source; an untrusted baseline falls back to the complete
52
+ // ordered driver. Every package still publishes its dist atomically. An installed package has no src tree
53
+ // and stays a pure Node -> dist execution path.
39
54
  const runtimeEntries = [
40
55
  cli,
41
56
  join(workspace, 'packages', 'spec-core', 'dist', 'index.js'),
42
- join(workspace, 'packages', 'session-core', 'dist', 'index.js'),
43
57
  join(workspace, 'spec-eval', 'dist', 'index.js'),
44
58
  join(workspace, 'spec-forge', 'dist', 'index.js'),
45
59
  ]
46
- const newestSource = srcRoots.reduce((newest, root) => {
47
- if (!existsSync(root)) return newest
48
- for (const entry of readdirSync(root, { recursive: true })) {
49
- const path = join(root, String(entry))
50
- try {
51
- if (/\.(ts|tsx|js|mjs)$/.test(path)) newest = Math.max(newest, statSync(path).mtimeMs)
52
- } catch { /* a concurrent source edit can only make the next invocation rebuild again */ }
60
+ // Tests and declaration files are typecheck inputs, not runtime build inputs. Counting them here makes a
61
+ // harmless test edit look like a stale CLI and rebuilds every workspace on the next hook invocation.
62
+ const isRuntimeSource = (path) => {
63
+ return /\.(ts|tsx|js|mjs)$/.test(path)
64
+ && !/\.d\.ts$/.test(path)
65
+ && !/(^|[./])[^/]+\.test\.(ts|tsx|js|mjs)$/.test(path)
66
+ }
67
+ const newestRuntimeSource = (root) => {
68
+ const roots = buildTargets.map(([packagePath]) => join(root, packagePath, 'src'))
69
+ return roots.reduce((newest, root) => {
70
+ if (!existsSync(root)) return newest
71
+ for (const entry of readdirSync(root, { recursive: true })) {
72
+ const path = join(root, String(entry))
73
+ try {
74
+ if (isRuntimeSource(path)) newest = Math.max(newest, statSync(path).mtimeMs)
75
+ } catch { /* a concurrent source edit can only make the next invocation rebuild again */ }
76
+ }
77
+ return newest
78
+ }, 0)
79
+ }
80
+ const newestPackageSource = (root, packagePath) => {
81
+ const source = join(root, packagePath, 'src')
82
+ if (!existsSync(source)) return 0
83
+ let newest = 0
84
+ const visit = (dir) => {
85
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
86
+ const path = join(dir, entry.name)
87
+ if (entry.isDirectory()) visit(path)
88
+ else if (isRuntimeSource(path)) {
89
+ try { newest = Math.max(newest, statSync(path).mtimeMs) } catch { /* retry on the next launch */ }
90
+ }
91
+ }
53
92
  }
93
+ visit(source)
54
94
  return newest
55
- }, 0)
56
- const rebuild = runtimeEntries.some((entry) => {
57
- try { return statSync(entry).mtimeMs < newestSource } catch { return true }
58
- })
59
- if (rebuild) {
60
- const build = spawnSync('npm', ['run', 'build'], { cwd: workspace, stdio: 'inherit' })
61
- if (build.error || build.status !== 0 || !existsSync(cli)) {
62
- console.error('spex: source workspace build failed; fix it, then retry.')
63
- process.exit(build.status ?? 1)
95
+ }
96
+ const entriesAreFresh = (root) => {
97
+ const newestSource = newestRuntimeSource(root)
98
+ return buildTargets.every(([packagePath, entry]) => {
99
+ try { return statSync(join(root, packagePath, entry)).mtimeMs >= newestSource } catch { return false }
100
+ })
101
+ }
102
+ const packageFingerprint = (root, packagePath) => {
103
+ const hash = createHash('sha256')
104
+ const packageRoot = join(root, packagePath)
105
+ const files = ['package.json', 'tsconfig.build.json'].map((name) => join(packageRoot, name)).filter(existsSync)
106
+ const visit = (dir, relative = '') => {
107
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
108
+ const path = join(dir, entry.name)
109
+ const child = relative ? `${relative}/${entry.name}` : entry.name
110
+ if (entry.isDirectory()) visit(path, child)
111
+ else if (isRuntimeSource(path)) files.push(path)
112
+ }
113
+ }
114
+ const source = join(packageRoot, 'src')
115
+ if (existsSync(source)) visit(source)
116
+ files.sort()
117
+ for (const path of files) hash.update(path.slice(packageRoot.length)).update('\0').update(readFileSync(path)).update('\0')
118
+ return hash.digest('hex')
119
+ }
120
+ const packageDependents = {
121
+ 'packages/session-protocol': ['packages/session-topology', 'packages/session-runtime', 'packages/session-events', 'packages/session-application', 'packages/session-selflaunch', 'spec-cli'],
122
+ 'packages/session-topology': ['packages/session-application', 'spec-cli'],
123
+ 'packages/session-runtime': ['packages/session-application', 'packages/session-selflaunch', 'spec-cli'],
124
+ 'packages/session-events': ['packages/session-application', 'spec-cli'],
125
+ 'packages/session-application': ['spec-cli'],
126
+ 'packages/session-selflaunch': ['spec-cli'],
127
+ 'packages/spec-core': ['spec-eval', 'spec-forge', 'spec-cli'],
128
+ 'spec-eval': ['spec-cli'],
129
+ 'spec-forge': ['spec-cli'],
130
+ 'spec-cli': [],
131
+ }
132
+ const changedBuildPackages = (root) => {
133
+ const changed = new Set(buildTargets
134
+ .map(([packagePath]) => packagePath)
135
+ .filter((packagePath) => packageFingerprint(workspace, packagePath) !== packageFingerprint(root, packagePath)))
136
+ for (const [packagePath, entry] of buildTargets) {
137
+ try {
138
+ if (statSync(join(root, packagePath, entry)).mtimeMs < newestPackageSource(workspace, packagePath)) changed.add(packagePath)
139
+ } catch { changed.add(packagePath) }
140
+ }
141
+ for (const packagePath of [...changed]) for (const dependent of packageDependents[packagePath]) changed.add(dependent)
142
+ return buildTargets.map(([packagePath]) => packagePath).filter((packagePath) => changed.has(packagePath))
143
+ }
144
+ const mainRoot = (() => {
145
+ const result = spawnSync('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], { cwd: workspace, encoding: 'utf8' })
146
+ if (result.status !== 0) return null
147
+ const commonDir = result.stdout.trim()
148
+ return commonDir ? dirname(commonDir) : null
149
+ })()
150
+ const reusableMainBuild = () => mainRoot && mainRoot !== workspace && entriesAreFresh(mainRoot)
151
+ const importMainBuild = () => {
152
+ for (const [packagePath] of buildTargets) {
153
+ const source = join(mainRoot, packagePath, 'dist')
154
+ const target = join(workspace, packagePath, 'dist')
155
+ const temporary = `${target}.from-main-${process.pid}`
156
+ const previous = `${target}.from-main-previous-${process.pid}`
157
+ rmSync(temporary, { recursive: true, force: true })
158
+ rmSync(previous, { recursive: true, force: true })
159
+ cpSync(source, temporary, { recursive: true })
160
+ if (existsSync(target)) renameSync(target, previous)
161
+ renameSync(temporary, target)
162
+ rmSync(previous, { recursive: true, force: true })
163
+ }
164
+ }
165
+ const sourceIsStale = () => {
166
+ const newestSource = newestRuntimeSource(workspace)
167
+ return runtimeEntries.some((entry) => {
168
+ try { return statSync(entry).mtimeMs < newestSource } catch { return true }
169
+ })
170
+ }
171
+ if (sourceIsStale()) {
172
+ // A hook, poller, and human shell can all invoke spex at once. The lock is deliberately at the
173
+ // launcher boundary: only one process pays the full workspace build, and waiters re-check freshness
174
+ // after the owner exits. A dead owner is recoverable; a live build gets two minutes before it is stale.
175
+ const buildLock = join(workspace, '.spex-build.lock')
176
+ const wait = new Int32Array(new SharedArrayBuffer(4))
177
+ const deadline = Date.now() + 120_000
178
+ let owner = false
179
+ while (!owner) {
180
+ try {
181
+ mkdirSync(buildLock)
182
+ owner = true
183
+ } catch (error) {
184
+ if (error?.code !== 'EEXIST') throw error
185
+ try {
186
+ if (Date.now() - statSync(buildLock).mtimeMs > 120_000) rmSync(buildLock, { recursive: true, force: true })
187
+ } catch { /* the owner may have completed between the stat and the next check */ }
188
+ if (Date.now() >= deadline) {
189
+ console.error('spex: source workspace build lock did not clear; remove .spex-build.lock after confirming no build is running.')
190
+ process.exit(75)
191
+ }
192
+ Atomics.wait(wait, 0, 0, 100)
193
+ }
194
+ }
195
+ try {
196
+ if (sourceIsStale()) {
197
+ if (reusableMainBuild()) {
198
+ const changed = changedBuildPackages(mainRoot)
199
+ console.error(`spex: importing fresh runtime closure from main; rebuilding ${changed.length} package(s)`)
200
+ importMainBuild()
201
+ if (changed.length) {
202
+ const build = spawnSync(process.execPath, [join(workspace, 'scripts', 'build-workspaces.mjs'), ...changed], { cwd: workspace, stdio: 'inherit' })
203
+ if (build.error || build.status !== 0 || !existsSync(cli)) {
204
+ console.error('spex: source workspace selective build failed; fix it, then retry.')
205
+ process.exit(build.status ?? 1)
206
+ }
207
+ }
208
+ } else {
209
+ const build = spawnSync('npm', ['run', 'build'], { cwd: workspace, stdio: 'inherit' })
210
+ if (build.error || build.status !== 0 || !existsSync(cli)) {
211
+ console.error('spex: source workspace build failed; fix it, then retry.')
212
+ process.exit(build.status ?? 1)
213
+ }
214
+ }
215
+ }
216
+ } finally {
217
+ rmSync(buildLock, { recursive: true, force: true })
64
218
  }
65
219
  }
66
220
  }
package/dist/cli.js CHANGED
@@ -67,7 +67,7 @@ async function assertDaemonDependencies(command) {
67
67
  // (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
68
68
  // paths route through the same printer.
69
69
  function fatal(e) {
70
- if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError', 'DashboardAssetError', 'GitWorkspaceError'].includes(e.name))
70
+ if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError', 'DashboardAssetError', 'GitWorkspaceError', 'SessionFileError'].includes(e.name))
71
71
  console.error(`spex: ${e.message}`);
72
72
  else
73
73
  console.error(e);
@@ -96,7 +96,7 @@ function flushExit(code = 0) {
96
96
  }
97
97
  const has = (name) => process.argv.includes(`--${name}`);
98
98
  // bare positionals after argv index `from`, skipping flags and their values (selectors for ls/watch).
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']);
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', '--candidate', '--path', '--owner', '--details', '--variant', '--cli', '--count', '--ids']);
100
100
  const EXPLICIT_BACKEND_ROUTE_FLAGS = ['api', 'port', 'password', 'insecure'];
101
101
  const EXPLICIT_BACKEND_VALUE_FLAGS = EXPLICIT_BACKEND_ROUTE_FLAGS
102
102
  .filter((name) => VALUE_FLAGS.has(`--${name}`))
@@ -213,6 +213,8 @@ function parseSessionSendArgs(args) {
213
213
  }
214
214
  if (positionals.length !== 2)
215
215
  sessionSendUsage('plain send requires exactly one selector and one message');
216
+ if (!positionals[1].trim())
217
+ sessionSendUsage('message must not be empty or whitespace');
216
218
  return { selector: positionals[0], kind: 'text', text: positionals[1], ...(values.has('--ssh') ? { sshAddress: values.get('--ssh') } : {}) };
217
219
  }
218
220
  const FULL_SESSION_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
@@ -339,6 +341,7 @@ async function followKit(selectors, verb) {
339
341
  const { localCachedSessions } = await import('./client.js');
340
342
  const { fromRaw, ownSessionId, selectSessions, toSession } = await import('./sessions.js');
341
343
  const { listSessionIds, readPublicRecordEntry } = await import('@spexcode/spec-core');
344
+ const { configuredSessionApplicationIfCutover, sessionApplicationCutoverState } = await import('./session-application.js');
342
345
  const real = selectors.filter((sel) => sel && sel !== '@all');
343
346
  let picked = [];
344
347
  if (real.length) {
@@ -353,9 +356,38 @@ async function followKit(selectors, verb) {
353
356
  // a fresh sample of the mutable record — that resample is what made the old poll lose moves.
354
357
  const row = (id, status, note) => {
355
358
  const entry = readPublicRecordEntry(id);
356
- if (entry.kind !== 'ok')
359
+ if (entry.kind === 'ok')
360
+ return { ...toSession(fromRaw(entry.raw), status, 'unknown'), note };
361
+ const cutover = sessionApplicationCutoverState();
362
+ const state = cutover === 'fresh' ? null : configuredSessionApplicationIfCutover()?.readState(id);
363
+ if (!state)
357
364
  return null;
358
- return { ...toSession(fromRaw(entry.raw), status, 'unknown'), note };
365
+ return {
366
+ id,
367
+ node: null,
368
+ branch: null,
369
+ path: '',
370
+ label: id,
371
+ title: id,
372
+ raw: { name: null, title: null },
373
+ parent: state.parentSessionId,
374
+ harness: 'unknown',
375
+ capabilities: { headless: false },
376
+ launcher: null,
377
+ lifecycle: state.status,
378
+ proposal: state.proposal,
379
+ merges: 0,
380
+ status,
381
+ liveness: 'unknown',
382
+ note,
383
+ archived: state.status === 'archived',
384
+ closedAt: null,
385
+ prompt: null,
386
+ promptPreview: null,
387
+ created: state.updatedAtMs,
388
+ activity: null,
389
+ sortKey: null,
390
+ };
359
391
  };
360
392
  const watcher = ownSessionId();
361
393
  return { watcher, follow: (emit, opts) => followSessions(emit, { ...opts, targets, self: watcher, row }) };
@@ -1062,9 +1094,9 @@ else if (cmd === 'session') {
1062
1094
  // the board comes from the backend (so it shows the sessions of whatever SPEXCODE_API_URL points at,
1063
1095
  // incl. a remote machine); selectSessions/formatTable are pure presentation, applied client-side.
1064
1096
  const { ownSessionId, resolveSession, selectChildren, selectSessions, formatTable } = await import('./sessions.js');
1065
- const { clientListSessions, clientListSessionsThroughPeer, clientSessionClosure } = await import('./client.js');
1066
- // The backend's default projection excludes cold archives. --all and an explicit selector request the
1067
- // history projection so an operator can still inspect or unarchive one deliberately.
1097
+ const { clientListSessions, clientListSessionsThroughPeer } = await import('./client.js');
1098
+ // The backend's default projection excludes closed records. --all and an explicit selector request the
1099
+ // retained record projection so an operator can inspect or resume one deliberately.
1068
1100
  const selectors = positionals(4);
1069
1101
  rejectUnknownBackendFlags('spex session ls', 4, ['status', 'all', 'json', 'ssh', 'children'], ['children']);
1070
1102
  const peerAnchor = parseSessionPeerAnchor('ls', selectors);
@@ -1117,19 +1149,10 @@ else if (cmd === 'session') {
1117
1149
  const selected = selectSessions(scoped, peerAnchor ? [] : selectors);
1118
1150
  const picked = flag('status') ? selected.filter((session) => flag('status').split(',').includes(session.status)) : selected;
1119
1151
  if (!peerAnchor && children === undefined && selectors.length === 1 && !selected.length && idLikeSelector(selectors[0])) {
1120
- const closure = await clientSessionClosure(selectors[0]);
1121
- if (closure) {
1122
- const result = { id: closure.id, status: 'closed', closedAt: closure.closedAt };
1123
- console.log(has('json') ? JSON.stringify([result], null, 2) : `${closure.id.slice(0, 8)}: closed at ${closure.closedAt}`);
1124
- }
1125
- else {
1126
- console.error(`spex session ls: ${selectors[0]} was not found in this project's live, archive, or terminal-close history`);
1127
- process.exit(2);
1128
- }
1129
- }
1130
- else {
1131
- console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked, true, scope));
1152
+ console.error(`spex session ls: ${selectors[0]} was not found in this project's session records`);
1153
+ process.exit(2);
1132
1154
  }
1155
+ console.log(has('json') ? JSON.stringify(picked, null, 2) : formatTable(picked, true, scope));
1133
1156
  }
1134
1157
  else if (sub === 'resources') {
1135
1158
  rejectUnknownBackendFlags('spex session resources', 4, ['json']);
@@ -1155,8 +1178,8 @@ else if (cmd === 'session') {
1155
1178
  }
1156
1179
  const files = await import('./session-files.js');
1157
1180
  if (verb === 'ls') {
1158
- for (const file of files.listSessionFiles(id))
1159
- console.log(file);
1181
+ for (const file of files.inspectSessionFiles(id))
1182
+ console.log(file.valid ? file.path : `INVALID ${file.path} — ${file.reason}`);
1160
1183
  }
1161
1184
  else if (verb === 'add') {
1162
1185
  const result = files.addSessionFile(id, path, withSessionRecordLockSync);
@@ -1389,14 +1412,6 @@ else if (cmd === 'session') {
1389
1412
  console.log(r.ok ? `interrupted ${full}` : `interrupt failed: ${r.error}`);
1390
1413
  process.exit(r.ok ? 0 : 1);
1391
1414
  }
1392
- else if (sub === 'archive' || sub === 'unarchive') {
1393
- // ARCHIVING/legacy unarchive ([[archive]]) — archive exact-stops before filing; unarchive signposts to
1394
- // resume and recreates the same conversation.
1395
- const on = sub === 'archive';
1396
- const full = await resolveSelectorOrExit(id);
1397
- const ok = await c.clientArchive(full, on);
1398
- console.log(!ok ? `no such session ${full}` : `${on ? 'archived' : 'resumed'} ${full}`);
1399
- }
1400
1415
  else if (sub === 'close') {
1401
1416
  const full = targetArgs?.sshAddress
1402
1417
  ? (FULL_SESSION_ID.test(id ?? '') ? id : sessionTargetUsage('close', '--ssh requires a full session id, not a selector'))
@@ -1688,47 +1703,59 @@ else if (cmd === 'internal') {
1688
1703
  // materializing the rollout — then stage the id + exact-payload proof for the session lifecycle owner and
1689
1704
  // print the thread id. The launch script then `resume`s it in the visible TUI.
1690
1705
  const { codexStartThread, codexTurn, waitForCodexRollout, codexBinary, codexSupportsBypassHookTrust, codexLauncherThreadPolicy } = await import('./harness.js');
1691
- const { stageHarnessLaunchProof } = await import('./sessions.js');
1706
+ const { existingHarnessLaunchTarget, stageHarnessLaunchProof } = await import('./sessions.js');
1692
1707
  const sock = process.argv[4], cwd = process.argv[5];
1693
1708
  const prompt = process.argv.slice(6).join(' ');
1694
1709
  if (!sock || !cwd) {
1695
1710
  console.error('usage: spex internal codex-launch <sock> <cwd> [prompt...]');
1696
1711
  process.exit(2);
1697
1712
  }
1698
- // On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
1699
- // the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
1700
- // the worktree's local hooks mirror materialize's capability decision so the two stay in lockstep.
1701
- const launcherCmd = process.env.SPEXCODE_CODEX_CMD || 'codex';
1702
- const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(launcherCmd));
1703
- // The governed record id rides into the thread's own shell environment (shell_environment_policy.set), so
1704
- // every command this thread spawns knows which session it is — the codex equivalent of the launch-injected
1705
- // id claude gets. codex-launch is exactly where both ids are known ([[harness-adapter]]).
1706
- const ownId = process.env.SPEXCODE_SESSION_ID?.trim();
1707
- const r = await codexStartThread(sock, cwd, bypassHookTrust, ownId ? { SPEXCODE_SESSION_ID: ownId } : undefined, codexLauncherThreadPolicy(launcherCmd));
1708
- if (!r.ok) {
1709
- console.error(r.error);
1710
- process.exit(1);
1713
+ const sid = process.env.SPEXCODE_SESSION_ID;
1714
+ // The visible TUI can fail after codex-launch has already staged the native id and first-turn proof
1715
+ // (for example, a remote-workspace validation error). The bounded shell retry re-enters this command;
1716
+ // reuse the proven target instead of minting a second thread or asking stageHarnessLaunchProof to consume
1717
+ // a payload that the lifecycle owner may already have committed.
1718
+ const existingTarget = sid ? existingHarnessLaunchTarget(sid) : null;
1719
+ if (existingTarget) {
1720
+ console.log(existingTarget);
1711
1721
  }
1712
- if (prompt) {
1713
- const t = await codexTurn(sock, r.threadId, prompt, cwd);
1714
- if (!t.ok) {
1715
- console.error(t.error);
1722
+ else {
1723
+ // On the bypass-trust path (the codex install supports the flag → materialize skipped writeCodexTrust's hash),
1724
+ // the thread the BACKEND owns must carry `bypass_hook_trust` in thread/start's config so the app-server fires
1725
+ // the worktree's local hooks — mirror materialize's capability decision so the two stay in lockstep.
1726
+ const launcherCmd = process.env.SPEXCODE_CODEX_CMD || 'codex';
1727
+ const bypassHookTrust = codexSupportsBypassHookTrust(codexBinary(launcherCmd));
1728
+ // The governed record id rides into the thread's own shell environment (shell_environment_policy.set), so
1729
+ // every command this thread spawns knows which session it is — the codex equivalent of the launch-injected
1730
+ // id claude gets. codex-launch is exactly where both ids are known ([[harness-adapter]]).
1731
+ const ownId = process.env.SPEXCODE_SESSION_ID?.trim();
1732
+ const { withCodexGenerationMutex } = await import('./codex-runtime-generations.js');
1733
+ const { runtimeRoot: projectRuntimeRoot } = await import('@spexcode/spec-core');
1734
+ const r = await withCodexGenerationMutex(projectRuntimeRoot(cwd), () => codexStartThread(sock, cwd, bypassHookTrust, ownId ? { SPEXCODE_SESSION_ID: ownId } : undefined, codexLauncherThreadPolicy(launcherCmd)));
1735
+ if (!r.ok) {
1736
+ console.error(r.error);
1716
1737
  process.exit(1);
1717
1738
  }
1718
- // The visible TUI resumes this thread from its ON-DISK rollout; a freshly-spawned app-server acks the turn
1719
- // but persists the rollout a few seconds LATE (verified live: the SAME thread's file lands at ~2-4s, not
1720
- // lost). WAIT for it to land BEFORE storing the id / printing it, else FAIL LOUD — never store a
1721
- // non-resumable harness_session_id (that permanently wedges every reopen). The 15s budget exceeds launch.sh's
1722
- // fast-fail threshold, so a real failure exits past it and the retry loop won't spray duplicate-prompt threads.
1723
- if (!await waitForCodexRollout(r.threadId, 20000)) {
1724
- console.error(`codex thread ${r.threadId} started but persisted no rollout within 20s app-server not ready; not storing a non-resumable id`);
1725
- process.exit(1);
1739
+ if (prompt) {
1740
+ const t = await codexTurn(sock, r.threadId, prompt, cwd);
1741
+ if (!t.ok) {
1742
+ console.error(t.error);
1743
+ process.exit(1);
1744
+ }
1745
+ // The visible TUI resumes this thread from its ON-DISK rollout; a freshly-spawned app-server acks the turn
1746
+ // but persists the rollout a few seconds LATE (verified live: the SAME thread's file lands at ~2-4s, not
1747
+ // lost). WAIT for it to land BEFORE storing the id / printing it, else FAIL LOUD — never store a
1748
+ // non-resumable harness_session_id (that permanently wedges every reopen). The 15s budget exceeds launch.sh's
1749
+ // fast-fail threshold, so a real failure exits past it and the retry loop won't spray duplicate-prompt threads.
1750
+ if (!await waitForCodexRollout(r.threadId, 20000)) {
1751
+ console.error(`codex thread ${r.threadId} started but persisted no rollout within 20s — app-server not ready; not storing a non-resumable id`);
1752
+ process.exit(1);
1753
+ }
1726
1754
  }
1755
+ if (sid)
1756
+ stageHarnessLaunchProof(sid, r.threadId, prompt);
1757
+ console.log(r.threadId);
1727
1758
  }
1728
- const sid = process.env.SPEXCODE_SESSION_ID;
1729
- if (sid)
1730
- stageHarnessLaunchProof(sid, r.threadId, prompt);
1731
- console.log(r.threadId);
1732
1759
  }
1733
1760
  else if (sub === 'opencode-capture') {
1734
1761
  // opencode MINTS its own session id (no launch flag pins it), so the generated plugin's FIRST event calls
@@ -1804,6 +1831,25 @@ else if (cmd === 'internal') {
1804
1831
  process.exit(1);
1805
1832
  }
1806
1833
  }
1834
+ else if (sub === 'codex-reopen') {
1835
+ // A headless resume reloads its evicted thread into the shared app-server (thread/resume, no turn), so
1836
+ // readiness — which proves online only for a RESIDENT thread — can see it. The visible TUI does this
1837
+ // implicitly by attaching; headless has no TUI, so its launch.sh resume branch calls this.
1838
+ const { codexReopenThread } = await import('./harness.js');
1839
+ const sock = process.argv[4], tid = process.argv[5];
1840
+ if (!sock || !tid) {
1841
+ console.error('usage: spex internal codex-reopen <sock> <threadId>');
1842
+ process.exit(2);
1843
+ }
1844
+ const r = await codexReopenThread(sock, tid);
1845
+ if (r.ok) {
1846
+ console.log('reopened');
1847
+ }
1848
+ else {
1849
+ console.error(r.error);
1850
+ process.exit(1);
1851
+ }
1852
+ }
1807
1853
  else if (sub === 'check-staged') {
1808
1854
  // the pre-commit hook's eval backstop: a staged stray evidence blob or malformed eval.md rejects the
1809
1855
  // commit. Logic lives in spec-eval; the hook shims here.
@@ -1845,6 +1891,22 @@ else if (cmd === 'internal') {
1845
1891
  const idled = mark(() => s.markIdle(sess));
1846
1892
  console.log(idled.ok ? 'idle' : idled.reason ?? 'noop (no session record, or not active)');
1847
1893
  }
1894
+ else if (sub === 'session-hook-state') {
1895
+ // Stop/idle/failure hooks are thin CLI shims. They may use the envelope for governed identity, but
1896
+ // lifecycle claims must come from the canonical session application after the JSON cutover.
1897
+ const id = flag('session');
1898
+ if (!id) {
1899
+ console.error('usage: spex internal session-hook-state --session <id>');
1900
+ process.exit(2);
1901
+ }
1902
+ const { sessionHookState } = await import('./sessions.js');
1903
+ const state = sessionHookState(id);
1904
+ if (!state)
1905
+ process.exit(1);
1906
+ // Tab-separated fields are deliberately closed vocabulary: governed, lifecycle, proposal. Notes are
1907
+ // not needed by the hook and must not become shell-parsed input.
1908
+ console.log([state.governed ? '1' : '0', state.status, state.proposal ?? ''].join('\t'));
1909
+ }
1848
1910
  else if (sub === 'commit-gate') {
1849
1911
  // the Stop gate's deterministic commit check (from cwd = the worktree): exit 0 if the node branch is
1850
1912
  // ready to declare done, else print the reason and exit 1. Takes the PROPOSAL being judged — `merge`
package/dist/client.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type CockpitReview } from './cockpit.js';
2
2
  import type { SessionEvalRevision } from '@spexcode/spec-eval/sessioneval';
3
- import { type Session, type SessionClosure, type Resolved, type DispatchResult } from './sessions.js';
3
+ import { type Session, type Resolved, type DispatchResult } from './sessions.js';
4
4
  export declare class BackendError extends Error {
5
5
  readonly status?: number | undefined;
6
6
  readonly transport?: unknown | undefined;
@@ -9,7 +9,6 @@ export declare class BackendError extends Error {
9
9
  export declare function backendConnectionRefused(error: unknown): boolean;
10
10
  export declare function localCachedSessions(includeArchived?: boolean): Session[];
11
11
  export declare function clientListSessions(includeArchived?: boolean): Promise<Session[]>;
12
- export declare function clientSessionClosure(selector: string): Promise<SessionClosure | null>;
13
12
  export declare function clientResources(): Promise<import('./host-resources.js').ResourceReport>;
14
13
  export declare function resolveClientSession(selector: string, includeArchived?: boolean): Promise<Resolved>;
15
14
  export type CaptureResult = {
@@ -94,7 +93,6 @@ export declare function clientRestoreQuarantine(id: string): Promise<{
94
93
  sha256: string;
95
94
  observedAt: string;
96
95
  }>;
97
- export declare function clientArchive(id: string, on?: boolean): Promise<boolean>;
98
96
  export declare function clientRename(id: string, name: string): Promise<boolean>;
99
97
  export declare function clientReparent(children: string[], parent: string | null): Promise<import('./sessions.js').SessionReparentResult>;
100
98
  export declare function clientSendRawKeys(id: string, keys: string[]): Promise<boolean>;