spexcode 0.5.8 → 0.6.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.
- package/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +185 -56
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +122 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/delivery-queue.ts +107 -0
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +71 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +5 -3
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +91 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +235 -1021
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +7 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
package/spec-cli/src/cli.ts
CHANGED
|
@@ -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 &&
|
|
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 {
|
|
136
|
-
const { sessionHeadline } = await import('./sessions.js')
|
|
137
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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('./
|
|
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
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
})
|
|
658
|
+
intervalMs: (Number(flag('interval')) || 1) * 1000,
|
|
659
|
+
})
|
|
728
660
|
} else if (sub === 'wait') {
|
|
729
|
-
const
|
|
730
|
-
const
|
|
731
|
-
const
|
|
732
|
-
|
|
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 own log'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
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 ('
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
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:
|
|
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
|
-
//
|
|
909
|
-
//
|
|
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
|
|
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
|