spexcode 0.5.8 → 0.5.9

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 (103) hide show
  1. package/package.json +5 -3
  2. package/spec-cli/bin/spex.mjs +1 -13
  3. package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
  4. package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
  5. package/spec-cli/hooks/dispatch.sh +15 -19
  6. package/spec-cli/src/anchors.ts +185 -56
  7. package/spec-cli/src/attach.ts +7 -17
  8. package/spec-cli/src/cli.ts +144 -225
  9. package/spec-cli/src/client.ts +114 -132
  10. package/spec-cli/src/cockpit.ts +43 -0
  11. package/spec-cli/src/codex-runtime-generations.ts +556 -0
  12. package/spec-cli/src/commit-surgery.ts +0 -14
  13. package/spec-cli/src/contract-filter.ts +0 -14
  14. package/spec-cli/src/doctor.ts +0 -16
  15. package/spec-cli/src/gateway-auth.ts +0 -13
  16. package/spec-cli/src/gateway.ts +0 -22
  17. package/spec-cli/src/git.ts +151 -48
  18. package/spec-cli/src/graph.ts +28 -7
  19. package/spec-cli/src/graphCache.ts +253 -72
  20. package/spec-cli/src/graphDelta.ts +0 -13
  21. package/spec-cli/src/graphStream.ts +70 -37
  22. package/spec-cli/src/guide.ts +29 -6
  23. package/spec-cli/src/harness.ts +232 -248
  24. package/spec-cli/src/help.ts +27 -26
  25. package/spec-cli/src/host-resources.ts +11 -2
  26. package/spec-cli/src/index.ts +70 -95
  27. package/spec-cli/src/issues-cli.ts +276 -0
  28. package/spec-cli/src/issues.ts +15 -197
  29. package/spec-cli/src/layout.ts +78 -2
  30. package/spec-cli/src/lint.ts +5 -3
  31. package/spec-cli/src/localIssues.ts +22 -185
  32. package/spec-cli/src/loop-in.ts +78 -0
  33. package/spec-cli/src/materialize.ts +0 -42
  34. package/spec-cli/src/mentions.ts +49 -193
  35. package/spec-cli/src/opencode-headless.ts +7 -7
  36. package/spec-cli/src/opencode.ts +2 -5
  37. package/spec-cli/src/pi-headless.ts +5 -5
  38. package/spec-cli/src/pty-bridge.ts +4 -5
  39. package/spec-cli/src/reviewSnapshot.ts +1 -0
  40. package/spec-cli/src/reviews.ts +6 -1
  41. package/spec-cli/src/root-lru.ts +54 -0
  42. package/spec-cli/src/runtime-guard.ts +0 -9
  43. package/spec-cli/src/session-cursors.ts +97 -0
  44. package/spec-cli/src/session-follow.ts +182 -0
  45. package/spec-cli/src/session-timeline.ts +55 -127
  46. package/spec-cli/src/sessions.ts +207 -1022
  47. package/spec-cli/src/shim-runtime.ts +9 -16
  48. package/spec-cli/src/specs.ts +33 -10
  49. package/spec-cli/src/uninstall.ts +0 -9
  50. package/spec-cli/src/uploads.ts +240 -18
  51. package/spec-cli/src/worktree-sources.ts +0 -16
  52. package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
  53. package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
  54. package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
  55. package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
  56. package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
  57. package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
  58. package/spec-cli/templates/spexcode.json +12 -0
  59. package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
  60. package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
  61. package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
  62. package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
  63. package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
  64. package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
  65. package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
  66. package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
  67. package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
  68. package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
  69. package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
  70. package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
  71. package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
  72. package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
  73. package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
  74. package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
  75. package/spec-dashboard/dist/index.html +2 -2
  76. package/spec-dashboard/src/session.js +1 -28
  77. package/spec-eval/src/cache.ts +0 -14
  78. package/spec-eval/src/cli.ts +26 -5
  79. package/spec-eval/src/evaltab.ts +67 -108
  80. package/spec-eval/src/filing.ts +0 -18
  81. package/spec-eval/src/freshness.ts +78 -32
  82. package/spec-eval/src/humanok.ts +0 -15
  83. package/spec-eval/src/scenariofresh.ts +8 -80
  84. package/spec-eval/src/scenarios.ts +16 -67
  85. package/spec-eval/src/sessioneval.ts +157 -78
  86. package/spec-eval/src/sidecar.ts +1 -70
  87. package/spec-eval/src/timeline.ts +0 -23
  88. package/spec-forge/src/cache.ts +34 -12
  89. package/spec-forge/src/cli.ts +0 -6
  90. package/spec-forge/src/drivers/github.ts +1 -12
  91. package/spec-forge/src/drivers/gitlab.ts +4 -19
  92. package/spec-forge/src/drivers.ts +0 -18
  93. package/spec-forge/src/links.ts +0 -4
  94. package/spec-forge/src/needs-eval.ts +0 -1
  95. package/spec-forge/src/port.ts +0 -12
  96. package/spec-forge/src/resident.ts +4 -20
  97. package/spec-cli/src/maintenance-wrapper.ts +0 -276
  98. package/spec-cli/src/session-maintenance.ts +0 -751
  99. package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
  100. package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
  101. package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
  102. package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
  103. /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
@@ -4,14 +4,22 @@ export {} // make this a module so top-level await is allowed
4
4
  // sigil ([[mentions]]).
5
5
  import { stripRefSigil } from './mentions.js'
6
6
 
7
- // @@@ noun-first dispatch ([[cli-surface]]) - `spex <noun> <verb> [object] [flags]`: the verb is always the
8
- // second token after its noun, a bare noun prints its drawer's help, and a bare verb exists only where the
9
- // object is invariably THIS PROJECT (graph · init · materialize · doctor · uninstall · serve). There is no
10
- // verb mirror and no promoted spelling — one verb, one spelling. Every REMOVED spelling lives in the
11
- // signpost tables below: it REPORTS the new spelling and exits non-zero, never executes (a signpost is not
12
- // an alias; the tables die in 0.4.0).
13
7
  const cmd = process.argv[2]
14
8
 
9
+ const DAEMON_DEPENDENCIES = ['hono', '@hono/node-server', '@hono/node-ws', 'node-pty']
10
+
11
+ async function assertDaemonDependencies(command: 'spex serve' | 'spex dashboard'): Promise<void> {
12
+ const { createRequire } = await import('node:module')
13
+ const resolve = createRequire(import.meta.url).resolve
14
+ const missing = DAEMON_DEPENDENCIES.filter((dependency) => {
15
+ try { resolve(dependency); return false } catch { return true }
16
+ })
17
+ if (!missing.length) return
18
+ console.error(`${command}: missing optional daemon dependencies: ${missing.join(', ')}`)
19
+ console.error(`Install them with: npm install ${missing.join(' ')}`)
20
+ process.exit(1)
21
+ }
22
+
15
23
  // Registered before any await so a fatal top-level error lands here. Errors we OWN — BackendError, the
16
24
  // loud malformed-config ConfigError, the --api/--port UsageError, the write-guard GuardError — are
17
25
  // matched BY NAME (to avoid importing them) and rendered as a one-line `spex: <message>` (a user's
@@ -20,8 +28,7 @@ const cmd = process.argv[2]
20
28
  // (loadConfig on a malformed spexcode.json) surfaces as uncaughtException, not unhandledRejection, so BOTH
21
29
  // paths route through the same printer.
22
30
  function fatal(e: unknown): never {
23
- if (e instanceof Error && e.name === 'SessionMaintenanceError') console.error(`spex: ${(e as Error & { code?: string }).code ?? 'maintenance_error'}: ${e.message}`)
24
- else if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError'].includes(e.name)) console.error(`spex: ${e.message}`)
31
+ if (e instanceof Error && ['BackendError', 'ConfigError', 'UsageError', 'GuardError'].includes(e.name)) console.error(`spex: ${e.message}`)
25
32
  else console.error(e)
26
33
  process.exit(1)
27
34
  }
@@ -74,9 +81,6 @@ function rejectUnknownFlags(command: string, from: number, allowed: readonly str
74
81
  }
75
82
  }
76
83
 
77
- // @@@ signposts (one version only — delete in 0.4.0) - every spelling v0.3.0 removed maps to its new home.
78
- // A signpost REPORTS and exits 2; it never executes (not an alias): a stale hook or a human's muscle memory
79
- // gets a readable failure that names the migration, and nothing old keeps silently working.
80
84
  const SIGNPOSTS: Record<string, string> = {
81
85
  search: 'spex spec search <query>',
82
86
  owner: 'spex spec owner <path>',
@@ -108,8 +112,6 @@ const SIGNPOSTS: Record<string, string> = {
108
112
  resolve: 'spex remark resolve <ref>',
109
113
  retract: 'spex remark retract <ref>',
110
114
  }
111
- // the session drawer's removed sub-spellings: rawkey folded into send; capture/prompt folded into show;
112
- // exit/reopen respelled stop/resume; the hook-only verbs moved to internal.
113
115
  const SESSION_SIGNPOSTS: Record<string, string> = {
114
116
  rawkey: 'spex session send <SEL> --keys "<keys>"',
115
117
  exit: 'spex session stop <SEL>',
@@ -127,45 +129,62 @@ function signpost(oldSpelling: string, newSpelling: string): never {
127
129
  }
128
130
  if (cmd !== undefined && SIGNPOSTS[cmd]) signpost(`spex ${cmd}`, SIGNPOSTS[cmd])
129
131
 
132
+ // @@@ followKit - the ONE seam where a CLI follow verb ([[session-follow]]) binds to what it follows.
133
+ // Resolution reads the LOCAL store, never a backend: following is reading a file, so it must answer with no
134
+ // `spex serve` running, and asking for the derived board would cost exactly the tmux/rendezvous probes a follow
135
+ // exists to eliminate. No selector = follow every session, re-enumerated each tick so one that launches
136
+ // mid-follow joins the feed; an explicit selector resolves once to a fixed set.
137
+ type FollowRest = Omit<import('./session-follow.js').FollowOpts, 'targets' | 'self' | 'row'>
138
+ async function followKit(selectors: string[], verb: string): Promise<{
139
+ watcher: string | null
140
+ follow: (emit: (line: string) => void, opts: FollowRest) => Promise<import('./session-follow.js').FollowOutcome>
141
+ }> {
142
+ const { followSessions } = await import('./session-follow.js')
143
+ const { localCachedSessions } = await import('./client.js')
144
+ const { fromRaw, ownSessionId, selectSessions, toSession } = await import('./sessions.js')
145
+ const { listSessionIds, readPublicRecordEntry } = await import('./layout.js')
146
+ const real = selectors.filter((sel) => sel && sel !== '@all')
147
+ let picked: string[] = []
148
+ if (real.length) {
149
+ picked = selectSessions(localCachedSessions(true), real).map((s) => s.id)
150
+ if (!picked.length) { console.error(`${verb}: no such session: ${real.join(' ')}`); process.exit(2) }
151
+ }
152
+ const targets = real.length ? () => picked : () => listSessionIds()
153
+ // A feed line needs a session's NAME, which is record data; its STATUS comes from the log event, never from
154
+ // a fresh sample of the mutable record — that resample is what made the old poll lose moves.
155
+ const row = (id: string, status: import('./sessions.js').DisplayStatus, note: string | null) => {
156
+ const entry = readPublicRecordEntry(id)
157
+ if (entry.kind !== 'ok') return null
158
+ return { ...toSession(fromRaw(entry.raw), status, 'unknown'), note }
159
+ }
160
+ const watcher = ownSessionId()
161
+ return { watcher, follow: (emit, opts) => followSessions(emit, { ...opts, targets, self: watcher, row }) }
162
+ }
163
+
130
164
  const greeted = new Set<string>()
165
+ // Starting a STREAM follow on named live sessions announces itself ([[session-follow]]): one appended line per
166
+ // target per process, so a stream never re-nags and a one-shot `wait` never announces at all. The append is the
167
+ // delivery, taken locally, so the announce needs no backend either.
131
168
  async function greetWatchTargets(watcher: string, selectors: string[]): Promise<void> {
132
169
  try {
133
170
  const real = selectors.filter((sel) => sel && sel !== '@all')
134
171
  if (!real.length) return
135
- const { resolveClientSession, clientSend } = await import('./client.js')
136
- const { sessionHeadline } = await import('./sessions.js')
137
- const meR = await resolveClientSession(watcher)
172
+ const { localCachedSessions } = await import('./client.js')
173
+ const { selectSessions, sendText, sessionHeadline } = await import('./sessions.js')
174
+ const board = localCachedSessions(true)
138
175
  // name the watcher by its board HEADLINE (same as the reply-channel footer), delimited as a session title.
139
- const me = 'ok' in meR ? sessionHeadline(meR.ok) : watcher
176
+ const mine = board.find((s) => s.id === watcher)
177
+ const me = mine ? sessionHeadline(mine) : watcher
140
178
  const meWho = me && me !== watcher ? `session "${me}" (${watcher})` : `session ${watcher}`
141
- for (const sel of real) {
142
- const r = await resolveClientSession(sel)
143
- if (!('ok' in r)) continue // none/ambiguous → don't guess a target to interrupt
144
- const target = r.ok.id
179
+ for (const target of selectSessions(board, real).map((s) => s.id)) {
145
180
  if (target === watcher || greeted.has(target)) continue
146
181
  greeted.add(target)
147
182
  const text = `🔭 ${meWho} is now supervising you — they started \`spex session watch\` over this session. To reach them directly, run: spex session send ${watcher} "<your message>". (One-time heads-up; reply only if you need to.)`
148
- void clientSend(target, text) // no sender id → the connection notice is not double-counted as comms
183
+ void sendText(target, text) // no sender id → the connection notice is not double-counted as comms
149
184
  }
150
185
  } catch { /* greeting is best-effort — it must never disturb the watch */ }
151
186
  }
152
187
 
153
- async function withWatchEdge<T>(selectors: string[], intervalMs: number, body: () => Promise<T>, greet = false): Promise<T> {
154
- const { ownSessionId, reportWatch, reportUnwatch } = await import('./sessions.js')
155
- const { randomUUID } = await import('node:crypto')
156
- const watcher = ownSessionId()
157
- if (!watcher) return body() // not a launched session (no own id) → nothing to attribute an edge to
158
- const token = randomUUID()
159
- const ttlMs = intervalMs * 3 // tolerate two missed heartbeats before the edge is dropped
160
- void reportWatch(token, watcher, selectors, ttlMs)
161
- if (greet) void greetWatchTargets(watcher, selectors) // one-shot connection handshake to specific targets
162
- const hb = setInterval(() => void reportWatch(token, watcher, selectors, ttlMs), intervalMs)
163
- const cleanup = () => { clearInterval(hb); void reportUnwatch(token) }
164
- process.once('SIGINT', () => { cleanup(); process.exit(0) })
165
- process.once('SIGTERM', () => { cleanup(); process.exit(0) })
166
- try { return await body() } finally { cleanup() } // one-shot `wait` clears on return; stream `watch` clears on signal
167
- }
168
-
169
188
  async function resolveSelectorOrExit(selector: string): Promise<string> {
170
189
  if (!selector) { console.error('spex: missing session selector (id | id-prefix | node | branch | . for self)'); process.exit(2) }
171
190
  const { resolveClientSession } = await import('./client.js')
@@ -202,11 +221,9 @@ async function evalExport(id: string): Promise<never> {
202
221
  // CURRENT board projection correctly flips back to active on the next tool call.
203
222
  const DECLARED = ' — recorded; the human sees it in the dashboard. This declaration remains in the session timeline; your next tool call flips only the current graph state back to active (the mark-active hook, by design).'
204
223
  // 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.
205
- 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.'
224
+ // @@@ the sweep never includes THIS session - the nudge names sessions as sweepable, and it is read at the one moment a session is thinking about closing. Said loosely it reads as permission to close yourself, which deletes the worktree the reading agent is running in. So the target is scoped to sessions the agent spawned, and the exclusion is stated rather than implied.
225
+ 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 throwaway session you spawned. 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 sweep never includes THIS session: you have PROPOSED that the human close it, and closing your own session would delete the worktree you are running in. This is a reminder to check, not a required step.'
206
226
 
207
- // @@@ session-state kit - the shared machinery behind the agent-authored state writers, used by BOTH the
208
- // typeable worker declarations (`spex session done|park|ask`) and the hook-only writers under
209
- // `spex internal session-*` — one diagnosis, one truncation-echo, either drawer.
210
227
  async function stateKit() {
211
228
  const s = await import('./sessions.js')
212
229
  const l = await import('./layout.js')
@@ -243,14 +260,11 @@ async function stateKit() {
243
260
  throw e
244
261
  }
245
262
  }
246
- // truncation transparency ([[state]]): the session table shows only the first NOTE_BOARD_LIMIT chars of a
247
- // note. When a declared note overflows that cap, the confirmation says so — length, what the board shows,
248
- // where the full text is readable — so the cut is visible to the author instead of silently eaten.
249
- // Taught ONCE per session: the first overflowing note prints the full notice and drops a sentinel beside
250
- // the record; later overflows stay silent (the rule was taught — a verbatim repeat on every park/ask is
251
- // noise, field-reported). A nudge riding the echo, never a gate: the declaration has already landed.
263
+ // Taught once per record: the CLI's explicit NOTE column is the only note display that cuts prose.
252
264
  const noteEcho = (note?: string): string => {
253
- if (!note || note.length <= s.NOTE_BOARD_LIMIT) return ''
265
+ if (!note) return ''
266
+ const tableCut = s.displayWidth(note) > s.NOTE_BOARD_LIMIT
267
+ if (!tableCut) return ''
254
268
  const wid = sess || s.ownSessionId()
255
269
  const rid = wid ? (l.readAliasedRawRecord(wid)?.session_id ?? wid) : null // sentinel lives in the RECORD's dir, so an aliased codex id lands on the same file
256
270
  if (rid) {
@@ -260,7 +274,7 @@ async function stateKit() {
260
274
  writeFileSync(sentinel, `${new Date().toISOString()}\n`) // only reached on a successful declaration (the echo rides the success branch)
261
275
  } catch { /* unreadable/unwritable store dir → fall through and teach again; never block the echo */ }
262
276
  }
263
- return `\nyour note is ${note.length} chars; the session table shows only the first ${s.NOTE_BOARD_LIMIT} the full text IS recorded, and readable via spex session review ${(wid || '<your-session>').slice(0, 8)} / spex session ls --json. (said once — later long notes won't repeat this.)`
277
+ return `\nyour note is ${note.length} chars the session table's NOTE column shows only the first ${s.NOTE_BOARD_LIMIT} display columns. the full text IS recorded, and readable via spex session review ${(wid || '<your-session>').slice(0, 8)} / spex session ls --json. (said once — later cut notes won't repeat this.)`
264
278
  }
265
279
  return { s, l, sess, noRecord, mark, noteEcho }
266
280
  }
@@ -281,6 +295,7 @@ if (cmd === 'serve') {
281
295
  // two processes, two verbs in one operator drawer.
282
296
  const target = positionals(3)[0]
283
297
  if (target === 'ui') {
298
+ await assertDaemonDependencies('spex serve')
284
299
  // the natural post-install UI: serve the bundled dashboard on its OWN port (loopback by default;
285
300
  // --host widens the bind for LAN/tailnet viewing), proxying /api + the terminal socket to a
286
301
  // separately-run `spex serve`. Replaces the dogfood-only `npm run web` (vite).
@@ -291,6 +306,7 @@ if (cmd === 'serve') {
291
306
  if (!Number.isInteger(port) || !Number.isInteger(apiPort)) { console.error('spex serve ui: --port and --api-port must be integers'); process.exit(2) }
292
307
  serveDashboardLocal({ port, apiPort, host })
293
308
  } else if (target === undefined || target === 'api') {
309
+ await assertDaemonDependencies('spex serve')
294
310
  // fail loud, not cryptic ([[platform-support]]): serve IS the entry to the session runtime, which needs a
295
311
  // POSIX host (tmux/bash/unix-sockets). On a non-POSIX host (native Windows) point at WSL2 and exit here,
296
312
  // before importing the supervisor spawns tsx into a downstream ENOENT.
@@ -312,6 +328,7 @@ if (cmd === 'serve') {
312
328
  process.exit(2)
313
329
  }
314
330
  } else if (cmd === 'dashboard') {
331
+ await assertDaemonDependencies('spex dashboard')
315
332
  // the HOST-level dashboard ([[host-gateway]]): ONE gateway for every project this user serves. The
316
333
  // engine is [[gateway-hub]] (routing + [[gateway-auth]] authorization: admin scope implicit from
317
334
  // loopback until an admin password is set; per-project gates as configured); the host layer mounts the
@@ -344,10 +361,6 @@ if (cmd === 'serve') {
344
361
  }
345
362
  console.log(text)
346
363
  } else if (cmd === 'graph') {
347
- // @@@ graph - the ONE assembled view (tree + worktree overlay + sessions), both faces of it: bare (with
348
- // --focus/--depth) renders the human-readable status-coloured tree; --json dumps the full payload —
349
- // identical to GET /api/graph, machine food. Colour degrades cleanly: off unless stdout is a tty, and
350
- // NO_COLOR always wins.
351
364
  if (flag('node') !== undefined) { console.error('spex graph: --node was renamed — use --focus <id>'); process.exit(2) }
352
365
  const { buildBoard } = await import('./graph.js')
353
366
  const focusRaw = flag('focus')
@@ -374,8 +387,6 @@ if (cmd === 'serve') {
374
387
  }
375
388
  await flushExit(0)
376
389
  } else if (cmd === 'spec') {
377
- // @@@ spec drawer - the governance graph's own verbs: search (topic → node), owner (file → node, the
378
- // reverse edge), lint (the spec↔code graph check — errors gate commits), ack (the drift stamp).
379
390
  const sub = process.argv[3]
380
391
  if (sub === undefined) {
381
392
  console.log((await import('./help.js')).commandHelp('spec'))
@@ -499,10 +510,6 @@ if (cmd === 'serve') {
499
510
  const { uninstall } = await import('./uninstall.js')
500
511
  uninstall(positionals(3)[0], { hooks: has('hooks') })
501
512
  } else if (cmd === 'eval') {
502
- // @@@ eval drawer - the measurement system's verbs: add (file a reading) · ls (read a node's timeline, or
503
- // — with an explicit --session, never type-sniffed — a session's aggregate) · lint (the measurement-layer
504
- // lint, pure advisory) · retract · clean. Node-scoped verbs live in spec-eval; the session read lives
505
- // here (it talks to the backend).
506
513
  const sub = process.argv[3]
507
514
  if (sub === undefined) {
508
515
  console.log((await import('./help.js')).commandHelp('eval'))
@@ -548,9 +555,6 @@ if (cmd === 'serve') {
548
555
  process.exit(2)
549
556
  }
550
557
  } else if (cmd === 'evidence') {
551
- // @@@ evidence drawer - the bare content-addressed transport pair ([[evidence-put]], [[evidence-get]]): put bytes
552
- // in the shared evidence cache / read them back by hash, decoupled from filing a reading. Thin route — the
553
- // cache lives in spec-eval. flushExit matters here: `get` pipes raw blob bytes to stdout.
554
558
  if (process.argv[3] === undefined) {
555
559
  console.log((await import('./help.js')).commandHelp('evidence'))
556
560
  } else {
@@ -558,24 +562,15 @@ if (cmd === 'serve') {
558
562
  await flushExit(await runEvidence(process.argv.slice(3)))
559
563
  }
560
564
  } else if (cmd === 'issue') {
561
- // @@@ issue drawer - the ONE issue surface ([[issues]]): `ls` is THE read — local + forge issues as ONE
562
- // store-tagged list, the supervisor's/human's drain view; `show <id>` the single-thread detail (the same
563
- // read GET /api/issues/:id serves); open/reply/close are store-routed (the SAME
564
- // createIssue/replyIssue/closeIssue the dashboard's API calls); `promote` moves a thread cross-store;
565
- // `links` traces forge issues/PRs onto spec nodes (read-only, spec-forge).
566
565
  if (process.argv[3] === undefined) {
567
566
  console.log((await import('./help.js')).commandHelp('issue'))
568
567
  } else {
569
- const { runIssues } = await import('./issues.js')
568
+ const { runIssues } = await import('./issues-cli.js')
570
569
  await flushExit(await runIssues(process.argv.slice(3)))
571
570
  }
572
571
  } else if (cmd === 'remark') {
573
- // @@@ remark drawer - the resolvable interaction primitive ([[remark-substrate]]): `add` pins a concern to
574
- // a HOST (a local issue, or a scenario `<node> --scenario <name>`), a second agent `resolve`s it, the
575
- // author `retract`s it. CLI-first — the whole loop is these thin store-write wrappers, so the dashboard
576
- // adds no capability.
577
572
  const sub = process.argv[3]
578
- const m = sub === 'add' || sub === 'resolve' || sub === 'retract' ? await import('./localIssues.js') : null
573
+ const m = sub === 'add' || sub === 'resolve' || sub === 'retract' ? await import('./issues-cli.js') : null
579
574
  if (sub === undefined) {
580
575
  console.log((await import('./help.js')).commandHelp('remark'))
581
576
  } else if (sub === 'add') {
@@ -590,9 +585,6 @@ if (cmd === 'serve') {
590
585
  process.exit(2)
591
586
  }
592
587
  } else if (cmd === 'materialize') {
593
- // @@@ materialize - surface nodes → manifest + AGENTS.md/CLAUDE.md block + shims + Codex
594
- // trust, for cwd's project. Anchored on git-native events only ([[commit-surgery]]): this verb, init,
595
- // session-worktree creation, and the planted pre-commit/post-checkout/post-merge hooks.
596
588
  const { materialize } = await import('./materialize.js')
597
589
  try {
598
590
  console.log(`materialized — content-hash ${materialize().contentHash}`)
@@ -603,11 +595,6 @@ if (cmd === 'serve') {
603
595
  process.exit(1)
604
596
  }
605
597
  } else if (cmd === 'doctor') {
606
- // @@@ doctor - the diagnosis surface ([[doctor]], né `self` — renamed: "self" read as the tool itself /
607
- // the global install, while the report is about THIS agent's wiring): does the materialized workflow
608
- // actually reach this agent? Bare `doctor` reports per-layer coverage (preconditions · git-hook floor ·
609
- // contract · hooks+handler-existence · backend) over the same HARNESSES materialize delivers through;
610
- // `--contract` prints the surface:system text; `--conflicts` just the double-delivery check. Thin route.
611
598
  const { runDoctor } = await import('./doctor.js')
612
599
  await flushExit(await runDoctor(process.argv.slice(3)))
613
600
  } else if (cmd === 'session') {
@@ -616,51 +603,6 @@ if (cmd === 'serve') {
616
603
  console.log((await import('./help.js')).commandHelp('session'))
617
604
  } else if (SESSION_SIGNPOSTS[sub]) {
618
605
  signpost(`spex session ${sub}`, SESSION_SIGNPOSTS[sub])
619
- } else if (sub === 'maintain') {
620
- const args = process.argv.slice(4)
621
- if (args.length === 1 && args[0] === '--status') {
622
- const state = await (await import('./client.js')).clientMaintenanceStatus()
623
- console.log(JSON.stringify(state, null, 2))
624
- } else {
625
- const separator = args.indexOf('--')
626
- if (separator < 0 || separator === args.length - 1) {
627
- console.error('usage: spex session maintain [--allow-stop SEL] [--allow-resume SEL[:force]] [--ttl-ms MS] [--wait-ms MS] -- <command> [args...]')
628
- process.exit(2)
629
- }
630
- const options = args.slice(0, separator)
631
- const command = args.slice(separator + 1)
632
- const capabilities: import('./session-maintenance.js').Capability[] = []
633
- let ttlMs = 30_000
634
- let waitMs = 10_000
635
- for (let i = 0; i < options.length; i++) {
636
- const option = options[i]
637
- if (option === '--api' || option === '--port') { i++; continue }
638
- if (option === '--ttl-ms' || option === '--wait-ms') {
639
- const value = Number(options[++i])
640
- if (!Number.isSafeInteger(value)) { console.error(`spex session maintain: ${option} requires an integer millisecond value`); process.exit(2) }
641
- if (option === '--ttl-ms') ttlMs = value
642
- else waitMs = value
643
- continue
644
- }
645
- if (option === '--allow-stop' || option === '--allow-resume') {
646
- const raw = options[++i]
647
- if (!raw) { console.error(`spex session maintain: ${option} requires a session selector`); process.exit(2) }
648
- if (option === '--allow-stop') {
649
- capabilities.push({ op: 'stop', sessionId: await resolveSelectorOrExit(raw) })
650
- } else {
651
- const force = raw.endsWith(':force')
652
- const selector = force ? raw.slice(0, -':force'.length) : raw
653
- capabilities.push({ op: 'resume', sessionId: await resolveSelectorOrExit(selector), force })
654
- }
655
- continue
656
- }
657
- console.error(`spex session maintain: unknown option ${option}`)
658
- process.exit(2)
659
- }
660
- if (capabilities.length === 0) { console.error('spex session maintain: at least one exact stop/resume capability is required'); process.exit(2) }
661
- const { runMaintenanceWrapper } = await import('./maintenance-wrapper.js')
662
- process.exit(await runMaintenanceWrapper({ capabilities, ttlMs, waitMs, command }))
663
- }
664
606
  } else if (sub === 'new') {
665
607
  // spex session new "<prompt>" (prompt = first positional or --prompt, or --prompt-file
666
608
  // <path>|- so a long multi-paragraph prompt never fights shell quoting — [[prompt-file]]).
@@ -706,72 +648,47 @@ if (cmd === 'serve') {
706
648
  if (has('json')) console.log(JSON.stringify(report, null, 2))
707
649
  else console.log((await import('./host-resources.js')).formatResourceReport(report))
708
650
  } else if (sub === 'watch') {
709
- const { watchSessions } = await import('./sessions.js')
710
- const { clientListSessions } = await import('./client.js')
711
651
  const selectors = positionals(4)
712
- const intervalMs = (Number(flag('interval')) || 5) * 1000
713
- // Broad watch keeps the default active-only event population, so the archive shelf is not replayed into
714
- // ordinary monitoring. Presence is a separate all-record read: hiding a cold row must not turn it into
715
- // "gone"/closed. An explicit selector opts into the history population so its archive/offline transition
716
- // remains observable.
717
- const history = () => clientListSessions(true)
718
- const events = selectors.length ? history : () => clientListSessions(false)
719
- await withWatchEdge(selectors, intervalMs, () => watchSessions((line) => console.log(line), {
720
- source: events,
721
- presenceSource: history,
722
- selectors,
652
+ const kit = await followKit(selectors, 'spex session watch')
653
+ if (kit.watcher) void greetWatchTargets(kit.watcher, selectors)
654
+ await kit.follow((line) => console.log(line), {
723
655
  statuses: flag('status')?.split(','),
724
656
  includeIdle: has('idle'),
725
657
  as: flag('as'),
726
- intervalMs,
727
- }), true) // greet=true: a stream watch greets its specific targets once; `wait` (one-shot) does not
658
+ intervalMs: (Number(flag('interval')) || 1) * 1000,
659
+ })
728
660
  } else if (sub === 'wait') {
729
- const { watchSessions, ownSessionId } = await import('./sessions.js')
730
- const { clientListSessions } = await import('./client.js')
731
- const [id] = positionals(4)
732
- if (!id) { console.error('usage: spex session wait <id> [--timeout SECONDS] [--interval SECONDS] [--idle]'); process.exit(2) }
733
- // point-of-use turn-freeze warning ([[session-edges]]): a managed agent that runs this wait in the FOREGROUND
661
+ const selectors = positionals(4)
662
+ const kit = await followKit(selectors, 'spex session wait')
663
+ const named = selectors.join(' ') || 'your inbox'
664
+ // point-of-use turn-freeze warning ([[session-follow]]): a managed agent that runs this wait in the FOREGROUND
734
665
  // freezes its whole turn until the target produces an edge — a warning that used to live only in help
735
666
  // prose, now said where it matters. Foreground vs background is invisible from here, so the hint prints
736
667
  // for ANY managed-agent shell (harmless in a background transcript), on stderr, and changes nothing else.
737
- const own = ownSessionId()
738
- if (own) console.error(`spex session wait: heads-up (managed agent ${own.slice(0, 8)}) — this command BLOCKS until it OBSERVES ${id} transition from non-actionable into an actionable status (edge-triggered: an already-actionable current state does NOT return it — to just read the state now, use \`spex session ls\`/\`review\`); run it in the BACKGROUND or it freezes your whole turn (its exit is your wake-up). Proceeding.`)
739
- const intervalMs = (Number(flag('interval')) || 2) * 1000
668
+ if (kit.watcher) console.error(`spex session wait: heads-up (managed agent ${kit.watcher.slice(0, 8)}) — this command BLOCKS until it OBSERVES ${named} transition from non-actionable into an actionable status, or a message arrives for you (edge-triggered: an already-actionable current state does NOT return it — to just read the state now, use \`spex session ls\`/\`review\`); run it in the BACKGROUND or it freezes your whole turn (its exit is your wake-up). Proceeding.`)
740
669
  const timeoutSec = Number(flag('timeout')) || 1200
741
- // `wait` addresses one explicit record. Read its history row for both events and presence so archive is an
742
- // offline transition, not a vanished session; only a missing all-record row is a genuine gone/closed result.
743
- const history = () => clientListSessions(true)
744
- const r = await withWatchEdge([id], intervalMs, () => watchSessions(() => {}, {
745
- source: history,
746
- presenceSource: history,
747
- selectors: [id],
670
+ const r = await kit.follow(() => {}, {
748
671
  includeIdle: has('idle'),
749
- intervalMs,
750
- until: {
751
- timeoutMs: timeoutSec * 1000,
752
- // the arrival state and each observed transition narrate on stderr AS THEY HAPPEN, so a backgrounded
753
- // wait's transcript is the state sequence itself; stdout stays the one machine verdict (the observed
754
- // path on an edge, or a transport token).
755
- onObserved: (st, was) => console.error(was
756
- ? `spex session wait: observed ${was} ${st}`
757
- : `spex session wait: current status ${st} — recorded as the path start; returns on the next non-actionable→actionable transition`),
758
- },
759
- }))
672
+ intervalMs: (Number(flag('interval')) || 1) * 1000,
673
+ take: true,
674
+ timeoutMs: timeoutSec * 1000,
675
+ // the arrival state and each observed transition narrate on stderr AS THEY HAPPEN, so a backgrounded
676
+ // wait's transcript is the state sequence itself; stdout stays the one machine verdict.
677
+ onObserved: (sid, st, was) => console.error(was
678
+ ? `spex session wait: observed ${sid.slice(0, 8)} ${was} → ${st}`
679
+ : `spex session wait: ${sid.slice(0, 8)} current status ${st} — recorded as the path start; returns on the next non-actionable→actionable transition`),
680
+ })
760
681
  // the observed status path is the stdout verdict: read the LAST token as the status reached. Printing the
761
682
  // whole path (not just the final status) is the point — a manager sees what the wait lived through
762
683
  // (e.g. review→working→close-pending across a merge dispatch), not a bare word out of context.
763
684
  if ('reached' in r) { console.log(r.path.join('→')); process.exit(0) }
764
- if ('gone' in r) { console.error(`spex session wait: no such (living) session ${id}`); process.exit(2) }
765
- // a backend failure is a verdict about the TRANSPORT, never the session ([[session-edges]], issue #40): it prints
766
- // its own outcome token on stdout — a word OUTSIDE the session-status vocabulary, so a supervisor reading
767
- // the one status line can never mistake "I could not reach the board" for "the session is offline" — and
768
- // exits 3, distinct from the plain no-edge timeout (1) and the vanished target (2).
769
- if ('backendDown' in r) {
770
- console.error(`spex session wait: ${r.backendDown}`)
771
- console.log(r.kind === 'unreachable' ? 'backend-unreachable' : 'backend-error')
772
- process.exit(3)
685
+ if ('mail' in r) {
686
+ console.error(`spex session wait: message from ${r.mail.from ?? 'a human'} ${r.mail.text}`)
687
+ console.log('message')
688
+ process.exit(0)
773
689
  }
774
- console.error(`spex session wait: timeout observed no non-actionable→actionable transition on ${id} within ${timeoutSec}s (status path: ${r.path.join('→') || 'never sighted'})`)
690
+ if ('gone' in r) { console.error(`spex session wait: session ${r.gone} is gone its store dir no longer exists`); process.exit(2) }
691
+ console.error(`spex session wait: timeout — observed no non-actionable→actionable transition on ${named} within ${timeoutSec}s (status path: ${r.path.join('→') || 'never sighted'})`)
775
692
  process.exit(1)
776
693
  } else if (sub === 'review') {
777
694
  const first = positionals(4)[0]
@@ -875,7 +792,7 @@ if (cmd === 'serve') {
875
792
  console.log(`closed ${full}`)
876
793
  } else if (sub === 'quarantine') {
877
794
  rejectUnknownFlags('spex session quarantine', 4, ['adapter', 'thread', 'tmux', 'worktree', 'branch', 'restore', 'api', 'port'])
878
- if (!id) { console.error('usage: spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <session-id> --worktree <absent-path> --branch <absent-branch>') ; process.exit(2) }
795
+ if (!id) { 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)') ; process.exit(2) }
879
796
  if (has('restore')) {
880
797
  // Quarantine addresses an unreadable row which selector resolution intentionally excludes. Both the
881
798
  // move and its reverse therefore take the literal exact id, with the backend proving record state.
@@ -884,7 +801,7 @@ if (cmd === 'serve') {
884
801
  } else {
885
802
  const adapter = flag('adapter'), tmux = flag('tmux'), worktree = flag('worktree'), branch = flag('branch')
886
803
  if (!adapter || !tmux || !worktree || !branch) {
887
- console.error('usage: spex session quarantine <ID> --adapter <harness> [--thread <native-id>] --tmux <session-id> --worktree <absent-path> --branch <absent-branch>')
804
+ 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)')
888
805
  process.exit(2)
889
806
  }
890
807
  const quarantined = await c.clientQuarantine(id, { adapter, thread: flag('thread') ?? null, tmux, worktree, branch })
@@ -905,8 +822,8 @@ if (cmd === 'serve') {
905
822
  console.error(`spex session send --keys: nothing delivered to ${full} (offline, unknown session, or no valid key token)`)
906
823
  process.exit(1)
907
824
  }
908
- // prompt dispatch is socket-only + fail-loud (the backend enforces it): a non-accepted prompt prints the
909
- // reason AND exits non-zero, so a manager/script never mistakes a dead dispatch for success.
825
+ // The backend decides send success at the timeline append. A dead adapter poke only delays context
826
+ // injection, while a refused record write prints the reason and exits non-zero.
910
827
  // BIDIRECTIONAL: stamp the SENDER (this send process's OWN session — the only process that knows it, via
911
828
  // ownSessionId from CLAUDE_CODE_SESSION_ID) + a one-line reply hint into the delivered
912
829
  // message, so the recipient can reply over the SAME send. The sender's row (hence its display label) is
@@ -974,8 +891,6 @@ if (cmd === 'serve') {
974
891
  }
975
892
  }
976
893
  } else if (cmd === 'internal') {
977
- // @@@ internal - the machine-plumbing namespace: verbs only generated hooks and launch scripts call,
978
- // kept OUT of the porcelain top level so `spex help`'s vocabulary is exactly what a human/agent types.
979
894
  const sub = process.argv[3]
980
895
  if (sub === 'trunk') {
981
896
  // print the resolved source-of-truth branch (layout.ts mainBranch(): config override → the main
@@ -996,32 +911,6 @@ if (cmd === 'serve') {
996
911
  if (!path) throw new Error(`governor '${owner.id}' has no live spec path`)
997
912
  console.log(`${owner.id}\t${path}`)
998
913
  }
999
- } else if (sub === 'maintenance-begin') {
1000
- const ownerPid = Number(process.argv[4]), sessionId = process.argv[5]
1001
- if (!Number.isInteger(ownerPid) || ownerPid <= 0 || !sessionId) {
1002
- console.error('usage: spex internal maintenance-begin <owner-pid> <session-id>')
1003
- process.exit(2)
1004
- }
1005
- const { exactProcessIdentity, maintenanceErrorPayload, sessionMaintenance } = await import('./session-maintenance.js')
1006
- const owner = exactProcessIdentity(ownerPid)
1007
- if (!owner) { console.error('maintenance_identity_unknown: hook dispatcher owner identity is not exact'); process.exit(2) }
1008
- try {
1009
- console.log(sessionMaintenance().beginExternalOperation({ op: 'hook-state', sessionId }, owner))
1010
- } catch (error) {
1011
- const payload = maintenanceErrorPayload(error)
1012
- console.error(payload ? `${payload.code}: ${payload.error}` : String(error))
1013
- process.exit(2)
1014
- }
1015
- } else if (sub === 'maintenance-end') {
1016
- const ticket = process.argv[4], ownerPid = Number(process.argv[5])
1017
- if (!ticket || !Number.isInteger(ownerPid) || ownerPid <= 0) {
1018
- console.error('usage: spex internal maintenance-end <ticket-id> <owner-pid>')
1019
- process.exit(2)
1020
- }
1021
- const { exactProcessIdentity, sessionMaintenance } = await import('./session-maintenance.js')
1022
- const owner = exactProcessIdentity(ownerPid)
1023
- if (!owner) { console.error('maintenance_identity_unknown: hook dispatcher owner identity is not exact'); process.exit(2) }
1024
- sessionMaintenance().finishExternalOperation(ticket, owner)
1025
914
  } else if (sub === 'shared-runtime-spawn') {
1026
915
  const [cwd, logFile, pidFile, receiptFile, command] = process.argv.slice(4, 9)
1027
916
  const args = process.argv.slice(9)
@@ -1029,25 +918,33 @@ if (cmd === 'serve') {
1029
918
  console.error('usage: spex internal shared-runtime-spawn <cwd> <log> <pid-file> <receipt-file> <command> [args...]')
1030
919
  process.exit(2)
1031
920
  }
1032
- const { readFileSync } = await import('node:fs')
1033
- const { runSessionOperation } = await import('./session-maintenance.js')
1034
921
  const { spawnDetachedRuntime } = await import('./runtime-ownership.js')
1035
- const delegateFdValue = process.env.SPEXCODE_MAINTENANCE_DELEGATE_FD
1036
- const delegateChannelPresent = delegateFdValue !== undefined
1037
- const delegateFd = Number(delegateFdValue)
1038
- let delegate = ''
1039
- if (delegateChannelPresent && Number.isInteger(delegateFd) && delegateFd >= 3) {
1040
- try { delegate = readFileSync(delegateFd, 'utf8').trim() } catch { delegate = '' }
1041
- }
1042
- const sessionId = process.env.SPEXCODE_MAINTENANCE_SESSION_ID?.trim()
1043
- || process.env.SPEXCODE_SESSION_ID?.trim() || 'shared-runtime'
1044
922
  const env = { ...process.env }
1045
- delete env.SPEXCODE_MAINTENANCE_DELEGATE_FD
1046
- delete env.SPEXCODE_MAINTENANCE_SESSION_ID
1047
923
  delete env.SPEXCODE_SESSION_ID
1048
- const runtime = await runSessionOperation({ op: 'shared-spawn', sessionId, ...(delegateChannelPresent ? { delegate } : {}) }, () =>
1049
- spawnDetachedRuntime({ cwd, logFile, pidFile, receiptFile, command, args, env }))
924
+ const runtime = await spawnDetachedRuntime({ cwd, logFile, pidFile, receiptFile, command, args, env })
1050
925
  console.log(runtime.pid)
926
+ } else if (sub === 'codex-generation-current') {
927
+ const [root, command] = process.argv.slice(4, 6)
928
+ if (!root || !command) { console.error('usage: spex internal codex-generation-current <runtime-root> <codex-command>'); process.exit(2) }
929
+ const { ensureCodexCurrentGeneration } = await import('./codex-runtime-generations.js')
930
+ const { spawnDetachedRuntime } = await import('./runtime-ownership.js')
931
+ const { sessionIdentityEnvVars } = await import('./harness.js')
932
+ const env = { ...process.env }
933
+ for (const key of sessionIdentityEnvVars()) delete env[key]
934
+ const endpoint = await ensureCodexCurrentGeneration(root, async (candidate) => {
935
+ await spawnDetachedRuntime({ cwd: root, logFile: candidate.logFile, pidFile: candidate.pidFile, receiptFile: candidate.receiptFile,
936
+ command, args: ['app-server', '--listen', `unix://${candidate.socketPath}`], env })
937
+ })
938
+ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
939
+ console.log(`sock=${quote(endpoint.socketPath)}; pid=${quote(endpoint.pidFile)}; receipt=${quote(endpoint.receiptFile)}; log=${quote(endpoint.logFile)}; export SPEXCODE_CODEX_GENERATION=${quote(endpoint.id)}`)
940
+ } else if (sub === 'codex-generation-session') {
941
+ const [root, sessionId, threadId] = process.argv.slice(4, 7)
942
+ if (!root || !sessionId || !threadId) { console.error('usage: spex internal codex-generation-session <runtime-root> <session-id> <thread-id>'); process.exit(2) }
943
+ const { resolveCodexGenerationForSession } = await import('./codex-runtime-generations.js')
944
+ const endpoint = resolveCodexGenerationForSession(root, sessionId, threadId)
945
+ if (!endpoint) { console.error(`no exact Codex generation binding for session ${sessionId} thread ${threadId}`); process.exit(1) }
946
+ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`
947
+ console.log(`sock=${quote(endpoint.socketPath)}; pid=${quote(endpoint.pidFile)}; receipt=${quote(endpoint.receiptFile)}; log=${quote(endpoint.logFile)}; export SPEXCODE_CODEX_GENERATION=${quote(endpoint.id)}`)
1051
948
  } else if (sub === 'codex-launch') {
1052
949
  // BACKEND-owned codex thread. On the shared per-project app-server: thread/start { cwd = this worktree }
1053
950
  // (codex loads that worktree's config/hooks/AGENTS.md), store the new id on the governed record (keyed by
@@ -1146,6 +1043,28 @@ if (cmd === 'serve') {
1146
1043
  const st = process.argv[4] as any
1147
1044
  const ok = mark(() => s.markState(st, { proposal: flag('propose') as any, note: flag('note'), sessionId: sess }))
1148
1045
  console.log(ok.ok ? `state -> ${st}${noteEcho(flag('note'))}` : ok.reason ?? noRecord())
1046
+ } else if (sub === 'session-cursor') {
1047
+ // the turn-boundary mail reader advances its own inbox cursor here ([[session-cursors]]) — the same
1048
+ // one-writer discipline as session-state: shell reads the file, it never rewrites it, so a follower's
1049
+ // entries in the same file cannot be clobbered by a partial shell write.
1050
+ const to = Number(flag('to'))
1051
+ const sess = flag('session')
1052
+ if (process.argv[4] !== 'inbox' || !sess || !Number.isFinite(to)) {
1053
+ console.error('usage: spex internal session-cursor inbox --session <id> --to <event-index>')
1054
+ process.exit(2)
1055
+ }
1056
+ const { advanceInbox, inboxCursor } = await import('./session-cursors.js')
1057
+ const { readAliasedRawRecord } = await import('./layout.js')
1058
+ // the hook may address a codex THREAD id; the cursor file is keyed by the record id, so resolve the alias
1059
+ // through the one seam that owns that rule.
1060
+ const record = readAliasedRawRecord(sess)
1061
+ if (!record) console.log('noop (no session record)')
1062
+ else {
1063
+ advanceInbox(record.session_id, to)
1064
+ // report where the cursor ACTUALLY is: advancing is monotonic, so a lower offer is ignored, and
1065
+ // echoing the request back would confirm a move that did not happen.
1066
+ console.log(`inbox -> ${inboxCursor(record.session_id)}`)
1067
+ }
1149
1068
  } else if (sub === 'session-fail') {
1150
1069
  // StopFailure is one native source for the shared active-only turn-failure CAS. A declaration or explicit
1151
1070
  // stop that landed first is authoritative, just as it is for Codex notifications and headless exits.