spexcode 0.2.7 → 0.3.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/README.md +56 -44
- package/package.json +3 -3
- package/spec-cli/bin/spex.mjs +2 -2
- package/spec-cli/hooks/dispatch.sh +1 -1
- package/spec-cli/hooks/harness.sh +26 -6
- package/spec-cli/src/attach.ts +2 -2
- package/spec-cli/src/cli.ts +695 -536
- package/spec-cli/src/client.ts +31 -30
- package/spec-cli/src/contract-filter.ts +1 -1
- package/spec-cli/src/doctor.ts +40 -13
- package/spec-cli/src/gateway.ts +11 -7
- package/spec-cli/src/git.ts +2 -2
- package/spec-cli/src/{board.ts → graph.ts} +44 -14
- package/spec-cli/src/{boardCache.ts → graphCache.ts} +41 -21
- package/spec-cli/src/{boardDelta.ts → graphDelta.ts} +1 -1
- package/spec-cli/src/graphStream.ts +288 -0
- package/spec-cli/src/guide.ts +123 -96
- package/spec-cli/src/harness-select.ts +2 -2
- package/spec-cli/src/harness.ts +30 -14
- package/spec-cli/src/help.ts +289 -384
- package/spec-cli/src/hooks.ts +1 -1
- package/spec-cli/src/index.ts +130 -103
- package/spec-cli/src/init.ts +9 -9
- package/spec-cli/src/issues.ts +89 -33
- package/spec-cli/src/layout.ts +5 -5
- package/spec-cli/src/lint.ts +73 -8
- package/spec-cli/src/localIssues.ts +42 -60
- package/spec-cli/src/materialize.ts +1 -1
- package/spec-cli/src/mentions.ts +15 -15
- package/spec-cli/src/migrate-table.ts +397 -0
- package/spec-cli/src/migrate.ts +386 -0
- package/spec-cli/src/ranker.ts +30 -4
- package/spec-cli/src/reaper.ts +117 -0
- package/spec-cli/src/search.bench.mjs +10 -10
- package/spec-cli/src/search.ts +1 -1
- package/spec-cli/src/sessions.ts +244 -138
- package/spec-cli/src/specs.ts +25 -15
- package/spec-cli/src/supervise.ts +2 -2
- package/spec-cli/src/tree.ts +5 -5
- package/spec-cli/src/uninstall.ts +4 -4
- package/spec-cli/templates/hooks/post-checkout +1 -1
- package/spec-cli/templates/hooks/post-merge +3 -3
- package/spec-cli/templates/hooks/pre-commit +9 -9
- package/spec-cli/templates/hooks/prepare-commit-msg +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/idle.sh +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/mark-active.sh +7 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/mark-active/spec.md +2 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/fail.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec-first.sh +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec-of-file.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/core/spec.md +21 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/stop-gate.sh +21 -21
- package/spec-cli/templates/spec/project/{.config → .plugins}/distill/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/extract/spec.md +4 -4
- package/spec-cli/templates/spec/project/{.config → .plugins}/forge-link/spec.md +1 -1
- package/spec-cli/templates/spec/project/{.config → .plugins}/regroup/spec.md +2 -2
- package/spec-cli/templates/spec/project/{.config → .plugins}/reproduce-before-fix/spec.md +3 -3
- package/spec-cli/templates/spec/project/{.config → .plugins}/spec.md +4 -4
- package/spec-cli/templates/spec/project/.plugins/supervisor/spec.md +8 -0
- package/spec-cli/templates/spec/project/{.config → .plugins}/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +1 -1
- package/spec-dashboard/dist/assets/{Dashboard-P0B9ukSG.js → Dashboard-C7Bzsv86.js} +9 -9
- package/spec-dashboard/dist/assets/EvalsPage-DKZZIdHq.js +2 -0
- package/spec-dashboard/dist/assets/{FoldToggle-BuQ0lokE.js → FoldToggle-D5iB4Ac2.js} +1 -1
- package/spec-dashboard/dist/assets/{IssuesPage-H-D8aHEl.js → IssuesPage-CMFTsQhg.js} +1 -1
- package/spec-dashboard/dist/assets/{MobileApp-oZXIeCPb.js → MobileApp-DwuTKgdP.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-Blr_MEdU.js → SessionInterface-CBS5_cmK.js} +3 -3
- package/spec-dashboard/dist/assets/{SessionWindow-LcCzBMU7.js → SessionWindow-CqAnjWfI.js} +2 -2
- package/spec-dashboard/dist/assets/{Settings-_yOye-In.js → Settings-BW5f0OaW.js} +1 -1
- package/spec-dashboard/dist/assets/{index-uGs9v_9o.css → index-Cc26X4ce.css} +1 -1
- package/spec-dashboard/dist/assets/{index-BhIslAau.js → index-Ce0wDyQS.js} +8 -8
- package/spec-dashboard/dist/index.html +2 -2
- package/{spec-yatsu → spec-eval}/src/cache.ts +8 -5
- package/{spec-yatsu → spec-eval}/src/cli.ts +164 -95
- package/{spec-yatsu → spec-eval}/src/evaltab.ts +24 -24
- package/{spec-yatsu → spec-eval}/src/filing.ts +7 -5
- package/{spec-yatsu → spec-eval}/src/freshness.ts +50 -24
- package/{spec-yatsu → spec-eval}/src/scenariofresh.ts +22 -17
- package/{spec-yatsu/src/yatsu.ts → spec-eval/src/scenarios.ts} +41 -26
- package/{spec-yatsu/src/proof.ts → spec-eval/src/sessioneval.ts} +59 -59
- package/{spec-yatsu → spec-eval}/src/sidecar.ts +7 -1
- package/{spec-yatsu → spec-eval}/src/timeline.ts +1 -1
- package/spec-forge/src/__fixtures__/github-forge.json +9 -9
- package/spec-forge/src/cli.ts +14 -13
- package/spec-forge/src/{needs-yatsu-eval.ts → needs-eval.ts} +6 -6
- package/spec-cli/src/boardStream.ts +0 -179
- package/spec-cli/templates/spec/project/.config/core/spec.md +0 -17
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +0 -8
- package/spec-dashboard/dist/assets/EvalsPage-BrvAGyc4.js +0 -2
- /package/spec-cli/templates/presets/careful/{.config → .plugins}/clarify-before-code/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/idle/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/session-fail/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-first/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/spec-of-file/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/core/stop-gate/spec.md +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/distill/digest.mjs +0 -0
- /package/spec-cli/templates/spec/project/{.config → .plugins}/memory-hygiene/spec.md +0 -0
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -40,7 +40,7 @@ import { stripRefSigil } from './mentions.js'
|
|
|
40
40
|
// queued → a prepared worktree held below the concurrency cap; the drainer launches it as a slot frees.
|
|
41
41
|
// (closed = the worktree AND the global record are removed; not a stored status)
|
|
42
42
|
// The agent only ever PROPOSES (awaiting); merge/close are human-only. Every proposal is reversible — nothing
|
|
43
|
-
// auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (
|
|
43
|
+
// auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (resume)
|
|
44
44
|
// deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
|
|
45
45
|
//
|
|
46
46
|
// Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
|
|
@@ -114,7 +114,7 @@ export type Session = {
|
|
|
114
114
|
id: string; node: string | null; branch: string | null; path: string
|
|
115
115
|
label: string; headline: string // the DERIVED display strings ([[session-label]]) — the only names surfaces read
|
|
116
116
|
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
117
|
-
parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex new` ran inside another session, else null; the frontend folds a child under it at read time
|
|
117
|
+
parent: string | null // the SPAWNING session's id ([[session-nesting]]) — set once at creation when `spex session new` ran inside another session, else null; the frontend folds a child under it at read time
|
|
118
118
|
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
119
119
|
launcher: string | null // the launcher profile this session launched under ([[launcher-select]]); null only for old records predating launchers
|
|
120
120
|
lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
|
|
@@ -332,37 +332,104 @@ async function procSnapshot(): Promise<ProcTable> {
|
|
|
332
332
|
}
|
|
333
333
|
return t
|
|
334
334
|
}
|
|
335
|
-
// @@@ LiveSnap - the ONE liveness snapshot the whole session list shares
|
|
336
|
-
// (
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
// `
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
|
|
335
|
+
// @@@ LiveSnap - the ONE liveness snapshot the whole session list shares, built from a SINGLE tmux spawn
|
|
336
|
+
// (`list-panes -a` yields every session's window presence, pane pid, AND pane title at once — every session has
|
|
337
|
+
// ≥1 pane). `windows` = our live tmux windows (id → PaneProbe: pane pid + the hot-tier `pidAlive` verdict + the
|
|
338
|
+
// legacy `procs` table when a pid-less codex session needs it); `titles` = each pane's RAW title (harness-
|
|
339
|
+
// interpreted later in paneActivity — claude: a self-authored task summary; codex: a spinner + the cwd folder
|
|
340
|
+
// name); `sockets` = the ids whose rendezvous socket has a LIVE LISTENER (connect-probed once here, not the
|
|
341
|
+
// file-exists lie — [[harness-adapter]]); `unproven` = the ids whose LISTENER probe could not conclude (timeout
|
|
342
|
+
// under load / EAGAIN off a full-but-alive backlog — see rendezvousListening's tri-state) — death UNPROVEN, so
|
|
343
|
+
// those rows read `unknown`, never `offline`; `probeFailed` = the tmux probe itself FAILED (timed out under
|
|
344
|
+
// load), DISTINCT from "tmux up, no sessions" — the former means death is UNPROVEN so those rows read `unknown`,
|
|
345
|
+
// the latter is authoritative and reads `offline`.
|
|
346
|
+
export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
|
|
347
|
+
|
|
348
|
+
// @@@ parseLivePanes - the pure parser for the SINGLE merged `list-panes -a -F
|
|
349
|
+
// '#{session_name}\t#{pane_pid}\t#{pane_title}'` snapshot: id → { panePid, title }. First pane per session wins
|
|
350
|
+
// (our sessions are single-pane by construction). The title is the remainder AFTER the 2nd tab, so a title that
|
|
351
|
+
// itself contains tabs survives intact. Exported so the parse is unit-auditable without a tmux spawn.
|
|
352
|
+
export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
|
|
353
|
+
const m = new Map<string, { panePid?: number; title?: string }>()
|
|
354
|
+
for (const line of out.split('\n')) {
|
|
355
|
+
if (!line) continue
|
|
356
|
+
const t1 = line.indexOf('\t')
|
|
357
|
+
const name = (t1 < 0 ? line : line.slice(0, t1)).trim()
|
|
358
|
+
if (!name || m.has(name)) continue // first pane per session wins
|
|
359
|
+
if (t1 < 0) { m.set(name, {}); continue }
|
|
360
|
+
const rest = line.slice(t1 + 1)
|
|
361
|
+
const t2 = rest.indexOf('\t')
|
|
362
|
+
const pid = Number((t2 < 0 ? rest : rest.slice(0, t2)).trim())
|
|
363
|
+
const title = t2 < 0 ? '' : rest.slice(t2 + 1)
|
|
364
|
+
m.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, title: title || undefined })
|
|
365
|
+
}
|
|
366
|
+
return m
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// @@@ agent.pid hot registry - the per-session death-latch backing BOTH hotSignature (the 100ms tier) and
|
|
370
|
+
// liveSnapshot's codex `pidAlive` verdict, so ONE latch rule serves both. keyed by session id → the last
|
|
371
|
+
// agent.pid { mtime, pid, deadLatched }. agentAlive statSyncs the pid file: missing → undefined (a
|
|
372
|
+
// pre-registration/old session — the warm tier / legacy tree-walk covers it); mtime changed → a relaunch wrote
|
|
373
|
+
// a fresh pid, so re-read + RESET the latch; then kill-0 the pid — alive (or EPERM) = true, ESRCH = false and
|
|
374
|
+
// LATCH permanently for this (pid, mtime) so a later pid-reuse by an unrelated process can never resurrect a
|
|
375
|
+
// dead session (the portable pid-reuse guard: death is irreversible per registration; only a NEW agent.pid
|
|
376
|
+
// write — a fresh mtime — resets). Sync fs + one kill-0 syscall, NO child process, so it stays honest under a
|
|
377
|
+
// thrashed event loop — exactly when a spawn-based probe would hang.
|
|
378
|
+
type PidEntry = { mtimeMs: number; pid: number; deadLatched: boolean }
|
|
379
|
+
const pidRegistry = new Map<string, PidEntry>()
|
|
380
|
+
function readAgentPid(p: string): number { try { return Number(readFileSync(p, 'utf8').trim()) } catch { return NaN } }
|
|
381
|
+
function agentAlive(id: string): boolean | undefined {
|
|
382
|
+
const pidPath = sessionArtifactPath(id, 'agent.pid')
|
|
383
|
+
let mtimeMs: number
|
|
384
|
+
try { mtimeMs = statSync(pidPath).mtimeMs } catch { pidRegistry.delete(id); return undefined } // no pid file → pre-registration
|
|
385
|
+
let e = pidRegistry.get(id)
|
|
386
|
+
if (!e || e.mtimeMs !== mtimeMs) { e = { mtimeMs, pid: readAgentPid(pidPath), deadLatched: false }; pidRegistry.set(id, e) }
|
|
387
|
+
if (e.deadLatched) return false // latched dead stays dead until a new write (fresh mtime)
|
|
388
|
+
if (!Number.isFinite(e.pid) || e.pid <= 0) return false
|
|
389
|
+
try { process.kill(e.pid, 0); return true }
|
|
390
|
+
catch (err) {
|
|
391
|
+
if ((err as NodeJS.ErrnoException).code === 'EPERM') return true // alive but not ours to signal
|
|
392
|
+
e.deadLatched = true // ESRCH → proven dead, latch it permanently
|
|
393
|
+
return false
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// @@@ needsCodexProcScan - the legacy ps-scan GATE, factored PURE so it is assertable. The whole-box `ps`
|
|
398
|
+
// (procSnapshot) is paid ONLY when a windowed CODEX session has NO registered agent.pid (a pre-registration
|
|
399
|
+
// launch that still needs the paneTreeRunsCodex tree-walk). A box with no codex, or all pid-registered
|
|
400
|
+
// launches, returns false → zero ps spawn. Self-extinguishes as pre-registration sessions close.
|
|
401
|
+
export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean }[]): boolean {
|
|
402
|
+
return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
|
|
403
|
+
}
|
|
404
|
+
|
|
344
405
|
async function liveSnapshot(): Promise<LiveSnap> {
|
|
345
406
|
const windows = new Map<string, PaneProbe>()
|
|
407
|
+
const titles = new Map<string, string>()
|
|
346
408
|
let out: string
|
|
347
409
|
try {
|
|
348
|
-
|
|
410
|
+
// ONE merged spawn replaces the old two (list-sessions + list-panes): window presence + pane pid + title.
|
|
411
|
+
out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_pid}\t#{pane_title}'], TMUX_PROBE_TIMEOUT_MS)
|
|
349
412
|
} catch (e) {
|
|
350
413
|
// a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
|
|
351
414
|
// non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
|
|
352
|
-
return { probeFailed: probeTimedOut(e), windows, sockets: new Set(), unproven: new Set() }
|
|
415
|
+
return { probeFailed: probeTimedOut(e), windows, titles, sockets: new Set(), unproven: new Set() }
|
|
353
416
|
}
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
windows.
|
|
417
|
+
// the hot-tier pid verdict per windowed session (latch-consistent with hotSignature) + the legacy-scan gate.
|
|
418
|
+
const legacy: { harness: string; hasPid: boolean }[] = []
|
|
419
|
+
for (const [id, p] of parseLivePanes(out)) {
|
|
420
|
+
windows.set(id, { panePid: p.panePid, pidAlive: agentAlive(id) })
|
|
421
|
+
if (p.title) titles.set(id, p.title)
|
|
422
|
+
if (windows.get(id)!.pidAlive === undefined) { const rec = readRecord(id); if (rec) legacy.push({ harness: rec.harness, hasPid: false }) }
|
|
423
|
+
}
|
|
424
|
+
// the whole-box ps table is gathered ONCE, and ONLY for the legacy pid-less-codex fallback (paneTreeRunsCodex).
|
|
425
|
+
if (needsCodexProcScan(legacy)) {
|
|
426
|
+
const procs = await procSnapshot().catch(() => undefined) // codex-only, auxiliary; its failure isn't a liveness failure
|
|
427
|
+
if (procs) for (const probe of windows.values()) probe.procs = procs
|
|
360
428
|
}
|
|
361
|
-
// LISTENER probe for every windowed session, once, in parallel (
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
//
|
|
365
|
-
// render it `unknown` instead of a false `offline` (issue #40's load-spike graveyard).
|
|
429
|
+
// LISTENER probe for every windowed session, once, in parallel (a live listener, not a lingering socket
|
|
430
|
+
// file). A codex session has no rvSock → instant ENOENT → proven dead for the socket axis (codex ignores it).
|
|
431
|
+
// The tri-state matters: 'unproven' (timeout/EAGAIN — a wedged or thrashed but possibly-alive listener) lands
|
|
432
|
+
// in `unproven`, never silently not-live, so liveness() renders `unknown` not a false `offline` (issue #40).
|
|
366
433
|
const ids = [...windows.keys()]
|
|
367
434
|
const listening = await Promise.all(ids.map((id) => rendezvousListening(id)))
|
|
368
435
|
const sockets = new Set<string>()
|
|
@@ -371,39 +438,43 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
371
438
|
if (listening[i] === 'live') sockets.add(id)
|
|
372
439
|
else if (listening[i] === 'unproven') unproven.add(id)
|
|
373
440
|
})
|
|
374
|
-
return { probeFailed: false, windows, sockets, unproven }
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// @@@
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
// id
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
441
|
+
return { probeFailed: false, windows, titles, sockets, unproven }
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// @@@ hotSignature - the 100ms zero-spawn death detector ([[state]] hot tier). NO child processes, NO async
|
|
445
|
+
// socket connects — sync fs + one kill-0 syscall per session, so it stays honest under a thrashed event loop
|
|
446
|
+
// (exactly when a spawn-based probe would hang). The id list is refreshed LAZILY (at most once/second) from
|
|
447
|
+
// listSessionIds; per call each id's verdict comes from agentAlive (the shared death-latch registry). A session
|
|
448
|
+
// with NO agent.pid (pre-registration/old) is SKIPPED here — the warm tier covers it. The fingerprint is the
|
|
449
|
+
// sorted `${id}:${alive?1:0}` pairs plus the id set, so it moves the instant a registered agent dies.
|
|
450
|
+
let hotIds: string[] = []
|
|
451
|
+
let hotIdsAt = 0
|
|
452
|
+
export async function hotSignature(): Promise<string> {
|
|
453
|
+
const now = Date.now()
|
|
454
|
+
if (now - hotIdsAt >= 1000) { hotIds = listSessionIds(); hotIdsAt = now }
|
|
455
|
+
const pairs: string[] = []
|
|
456
|
+
const present: string[] = []
|
|
457
|
+
for (const id of hotIds) {
|
|
458
|
+
const alive = agentAlive(id)
|
|
459
|
+
if (alive === undefined) continue // no agent.pid → the warm tier's concern, not the hot death detector
|
|
460
|
+
present.push(id)
|
|
461
|
+
pairs.push(`${id}:${alive ? 1 : 0}`)
|
|
391
462
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// on window churn.
|
|
463
|
+
// prune latch entries for ids no longer registered (closed sessions), keeping the registry bounded.
|
|
464
|
+
const live = new Set(hotIds)
|
|
465
|
+
for (const k of [...pidRegistry.keys()]) if (!live.has(k)) pidRegistry.delete(k)
|
|
466
|
+
return pairs.sort().join(',') + '|' + present.sort().join(',')
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// @@@ warmSignature - the 1s tier ([[state]] warm tier): the SINGLE merged tmux snapshot (windows + pane pids +
|
|
470
|
+
// titles) plus the rendezvous listener tri-state, fingerprinted so a socket dying (claude exit), the probe
|
|
471
|
+
// flipping to unknown, a listener wedging (unproven), or a headline changing pushes a board-changed the instant
|
|
472
|
+
// it happens — not on window churn alone. Sorted so it only moves on a real change; NO git, NO extra store walk.
|
|
473
|
+
export async function warmSignature(): Promise<string> {
|
|
474
|
+
const snap = await liveSnapshot()
|
|
405
475
|
return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
|
|
406
|
-
[...snap.sockets].sort().join(',') + '~' + [...snap.unproven].sort().join(',') + '|' +
|
|
476
|
+
[...snap.sockets].sort().join(',') + '~' + [...snap.unproven].sort().join(',') + '|' +
|
|
477
|
+
[...snap.titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
|
|
407
478
|
}
|
|
408
479
|
|
|
409
480
|
// @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
|
|
@@ -487,7 +558,7 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
|
487
558
|
// show through: awaiting → its proposal label; parked/error/asking/queued → themselves; active/idle → their
|
|
488
559
|
// liveness (offline/starting/unknown), else the active-only idle/working inference (the mark-active hook flips
|
|
489
560
|
// idle → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
|
|
490
|
-
// terminal-mount and the relaunch panel on; this label is for badges and `spex ls`.
|
|
561
|
+
// terminal-mount and the relaunch panel on; this label is for badges and `spex session ls`.
|
|
491
562
|
function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
|
|
492
563
|
if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
|
|
493
564
|
if (rec.status !== 'active' && rec.status !== 'idle') return rec.status // parked | error | asking | queued (no tmux yet)
|
|
@@ -498,7 +569,7 @@ function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
|
|
|
498
569
|
|
|
499
570
|
// resolve a session id to its record + worktree. Now a DIRECT store read (the record carries worktree_path),
|
|
500
571
|
// not a scan of every worktree reading its `.session` — O(1) and exact. null when the id has no governed-or-not
|
|
501
|
-
// record. Shape kept ({path, branch, rec}) so the many callers (rename/propose/
|
|
572
|
+
// record. Shape kept ({path, branch, rec}) so the many callers (rename/propose/resume/merge/close/…) are unchanged.
|
|
502
573
|
async function findWorktree(id: string): Promise<{ path: string; branch: string | null; rec: SessRec } | null> {
|
|
503
574
|
const rec = readRecord(id)
|
|
504
575
|
if (!rec) return null
|
|
@@ -557,17 +628,17 @@ const lastKnownSession = new Map<string, Session>()
|
|
|
557
628
|
// non-governed (user-self-launched) records are excluded — board state is a managed-session concern ([[state]]).
|
|
558
629
|
// Offline and awaiting ones still appear (their record persists), so a session is never lost from view.
|
|
559
630
|
export async function listSessions(): Promise<Session[]> {
|
|
560
|
-
// ONE store enumeration + ONE tmux
|
|
561
|
-
//
|
|
562
|
-
const [ids, snap
|
|
563
|
-
Promise.resolve(listSessionIds()), liveSnapshot(),
|
|
631
|
+
// ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
|
|
632
|
+
// every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
|
|
633
|
+
const [ids, snap] = await Promise.all([
|
|
634
|
+
Promise.resolve(listSessionIds()), liveSnapshot(),
|
|
564
635
|
])
|
|
565
636
|
const rows = ids.map((id) => guardSession(id, () => {
|
|
566
637
|
const rec = readRecord(id)
|
|
567
638
|
if (!rec || !rec.governed) { lastKnownSession.delete(id); return null } // no record, or a self-launched (non-board) one
|
|
568
639
|
// the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
|
|
569
640
|
// is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
|
|
570
|
-
const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), titles.get(id))
|
|
641
|
+
const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), snap.titles.get(id))
|
|
571
642
|
const s = toSession(rec, reconcile(rec, snap), liveness(rec, snap), activity)
|
|
572
643
|
lastKnownSession.set(id, s)
|
|
573
644
|
return s
|
|
@@ -581,7 +652,7 @@ export async function listSessions(): Promise<Session[]> {
|
|
|
581
652
|
const liveIds = new Set(ids)
|
|
582
653
|
for (const k of [...lastKnownSession.keys()]) if (!liveIds.has(k)) lastKnownSession.delete(k)
|
|
583
654
|
// @@@ creation order - order by birth (oldest first): each session keeps its slot for life and a new one
|
|
584
|
-
// simply appends — a stable spatial map across every surface (dashboard window, session tabs, `spex ls`).
|
|
655
|
+
// simply appends — a stable spatial map across every surface (dashboard window, session tabs, `spex session ls`).
|
|
585
656
|
// `created` is the record's stored createdAt (set once at launch). A manual drag ([[session-reorder]])
|
|
586
657
|
// overrides one row's slot via a pseudo-time `sortKey`, so sort by `sortKey ?? created`; id breaks ties so
|
|
587
658
|
// same-instant births (or sort-keys) stay deterministic.
|
|
@@ -597,18 +668,18 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
|
|
|
597
668
|
}
|
|
598
669
|
|
|
599
670
|
// @@@ session graph = LIVE monitors, not a stored relationship. An edge A→B means "agent A is RIGHT NOW
|
|
600
|
-
// running `spex watch B` (the Monitor tool) over B" — derived from live watch registrations, never a
|
|
601
|
-
// persisted subscription. When a `spex watch` process starts it registers here and heartbeats; the edge
|
|
671
|
+
// running `spex session watch B` (the Monitor tool) over B" — derived from live watch registrations, never a
|
|
672
|
+
// persisted subscription. When a `spex session watch` process starts it registers here and heartbeats; the edge
|
|
602
673
|
// exists ONLY while that watch runs (deregistered on exit, dropped on a missed heartbeat). Single owner:
|
|
603
|
-
// this in-memory map in the SERVER process — the watch process (a separate `spex watch`) talks to it over
|
|
604
|
-
// HTTP (POST /api/sessions/
|
|
674
|
+
// this in-memory map in the SERVER process — the watch process (a separate `spex session watch`) talks to it over
|
|
675
|
+
// HTTP (POST /api/sessions/edges/watch + …/unwatch). No datastore, no file: a backend restart starts
|
|
605
676
|
// empty and live watches re-register on their next heartbeat. Kept isolated from the board assembler.
|
|
606
677
|
// an edge is either a LIVE monitor arrow (A→B = A watches B, directed) or a recorded comms link (A↔B =
|
|
607
678
|
// they have exchanged `count` direct messages, undirected). The dashboard renders the two kinds apart.
|
|
608
679
|
export type Edge = { from: string; to: string; kind: 'monitor' | 'comms'; count?: number }
|
|
609
680
|
|
|
610
681
|
// @@@ comms log - direct agent talk ([[comms-edge]]), recorded per-worktree. `spex session send` goes
|
|
611
|
-
// THROUGH the backend (
|
|
682
|
+
// THROUGH the backend (sendText); on a delivered message that carries a sender, the backend appends one
|
|
612
683
|
// {peer, ts} line to the RECIPIENT's comms log — each message counted exactly once, on the side the backend
|
|
613
684
|
// already resolved. Persisted (survives a backend restart, unlike the in-memory monitor registrations) and
|
|
614
685
|
// untracked, in the session's GLOBAL store dir (`comms.ndjson`, keyed by session_id) — it dies with the
|
|
@@ -631,7 +702,7 @@ function readComms(id: string): string[] {
|
|
|
631
702
|
.map((l) => { try { return String(JSON.parse(l).peer || '') } catch { return '' } }).filter(Boolean)
|
|
632
703
|
} catch { return [] }
|
|
633
704
|
}
|
|
634
|
-
// keyed by an opaque per-watch token (one per `spex watch` process), so a single agent may run several
|
|
705
|
+
// keyed by an opaque per-watch token (one per `spex session watch` process), so a single agent may run several
|
|
635
706
|
// monitors without them clobbering each other. `selectors` is what the watch targets (resolved LIVE at
|
|
636
707
|
// read time, not frozen here); empty / @all = a GLOBAL watcher. `expires` is the heartbeat backstop.
|
|
637
708
|
type WatchReg = { watcher: string; selectors: string[]; expires: number }
|
|
@@ -644,7 +715,7 @@ export function registerWatch(token: string, watcher: string, selectors: string[
|
|
|
644
715
|
watches.set(token, { watcher, selectors: selectors.filter(Boolean), expires: Date.now() + Math.max(1000, ttlMs) })
|
|
645
716
|
return true
|
|
646
717
|
}
|
|
647
|
-
// deregister a watch (its `spex watch` exited); false if the token wasn't registered.
|
|
718
|
+
// deregister a watch (its `spex session watch` exited); false if the token wasn't registered.
|
|
648
719
|
export function deregisterWatch(token: string): boolean { return watches.delete(token) }
|
|
649
720
|
// the still-live registrations, pruning any whose heartbeat lapsed — the backstop for a watch that died
|
|
650
721
|
// without a clean unwatch (SIGKILL, a dropped connection, a backend that was down at exit time).
|
|
@@ -765,13 +836,13 @@ let apiBaseMemo: Promise<ApiBaseInfo> | null = null
|
|
|
765
836
|
export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
|
|
766
837
|
export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
|
|
767
838
|
|
|
768
|
-
// @@@ watch registration (CLIENT side) - a `spex watch` process is separate from the server, so it
|
|
839
|
+
// @@@ watch registration (CLIENT side) - a `spex session watch` process is separate from the server, so it
|
|
769
840
|
// REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
|
|
770
841
|
// deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
|
|
771
842
|
// streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
|
|
772
843
|
// the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
|
|
773
844
|
// Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
|
|
774
|
-
// re-exported here so callers keep one name. Used by `spex watch` + the agent-typed `spex session …`
|
|
845
|
+
// re-exported here so callers keep one name. Used by `spex session watch` + the agent-typed `spex session …`
|
|
775
846
|
// declarations; the hooks instead pass `--session <id>` from the payload, so they never depend on this.
|
|
776
847
|
export const ownSessionId = envSessionId
|
|
777
848
|
|
|
@@ -798,8 +869,8 @@ async function postJSON(path: string, body: unknown): Promise<void> {
|
|
|
798
869
|
} catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
|
|
799
870
|
}
|
|
800
871
|
export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
|
|
801
|
-
postJSON('/api/sessions/
|
|
802
|
-
export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/sessions/
|
|
872
|
+
postJSON('/api/sessions/edges/watch', { token, watcher, selectors, ttlMs })
|
|
873
|
+
export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/sessions/edges/unwatch', { token })
|
|
803
874
|
|
|
804
875
|
// @@@ isBackendDown - a `client.ts` BackendError surfacing in the watch poll loop (whose session
|
|
805
876
|
// `source` is the HTTP backend client). Matched by NAME, not `instanceof`, so sessions.ts never imports
|
|
@@ -810,7 +881,7 @@ export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.na
|
|
|
810
881
|
// @@@ isBackendUnreachable - the TRANSIENT subset of isBackendDown: the fetch itself failed (nothing
|
|
811
882
|
// listening — ECONNREFUSED / "fetch failed"), which client.ts throws as a BackendError with NO HTTP
|
|
812
883
|
// `status`. An HTTP BackendError (the backend answered non-2xx) DOES carry a status and is a real error, not
|
|
813
|
-
// a momentary blip. The distinction matters to `spex wait`: a supervisor's backgrounded wait must survive
|
|
884
|
+
// a momentary blip. The distinction matters to `spex session wait`: a supervisor's backgrounded wait must survive
|
|
814
885
|
// the ~1s window where the supervisor reboots its hot-reloaded child behind the stable port, retrying until
|
|
815
886
|
// the backend answers again or the deadline hits — never dying on the in-flight fetch that a sibling merge's
|
|
816
887
|
// restart happens to interrupt. Read via a structural cast (no client.ts import — that would be a cycle).
|
|
@@ -831,7 +902,7 @@ export const slugify = (s: string | null) =>
|
|
|
831
902
|
// the FIRST `[[<id>]]` topic reference ([[mentions]]: `[[node]]` is a topic, `@` is now an actor/session).
|
|
832
903
|
// When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
|
|
833
904
|
// The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
|
|
834
|
-
// (`.
|
|
905
|
+
// (`.plugins`) keeps the dot — without `\.?` here `[[.plugins]]` captures nothing and never resolves to a node.
|
|
835
906
|
// Token chars are ANY unicode letter/number (slugify's already-made choice): a CJK dir name is a legal node
|
|
836
907
|
// id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
|
|
837
908
|
// node-agnostic.
|
|
@@ -870,6 +941,9 @@ export function launcherCmd(rec: SessRec): string | undefined {
|
|
|
870
941
|
if (rec.launchCmd) return rec.launchCmd
|
|
871
942
|
return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
|
|
872
943
|
}
|
|
944
|
+
// @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
|
|
945
|
+
// invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
|
|
946
|
+
const shq1 = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`
|
|
873
947
|
export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
|
|
874
948
|
const file = join(storeDir(id), 'launch.sh')
|
|
875
949
|
// NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
|
|
@@ -878,6 +952,16 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
878
952
|
// `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
|
|
879
953
|
// ambient default so resume reuses the same auth. Undefined is only for old records before launch_cmd existed.
|
|
880
954
|
const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`
|
|
955
|
+
// @@@ birth registration - record the AGENT's real pid BEFORE exec, the anchor of the 100ms hot death tier
|
|
956
|
+
// ([[state]]). Each attempt runs `sh -c '<pid-write>; exec env <invocation>'`: the sh writes its own `$$` to
|
|
957
|
+
// agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
|
|
958
|
+
// (claude: env→(reclaude→)claude; codex: env→bash -lc <script> whose last line is `exec codex … resume`), and
|
|
959
|
+
// `$$` therefore IS the launched agent's pid. `env` carries the leading `VAR=val` assignments (an env prefix
|
|
960
|
+
// can't lead an `exec`), and the whole payload is single-quoted for the outer shell (shq1) so the
|
|
961
|
+
// invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
|
|
962
|
+
// parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
|
|
963
|
+
const pidPath = join(storeDir(id), 'agent.pid')
|
|
964
|
+
const born = `sh -c ${shq1(`printf %s "$$" > ${shq1(pidPath)}; exec env ${invocation}`)}`
|
|
881
965
|
// Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
|
|
882
966
|
// ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
|
|
883
967
|
// has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
|
|
@@ -888,7 +972,7 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
888
972
|
writeFileSync(file, [
|
|
889
973
|
`for __spex_try in 1 2 3; do`,
|
|
890
974
|
` __spex_t0=$SECONDS`,
|
|
891
|
-
` ${
|
|
975
|
+
` ${born}`,
|
|
892
976
|
` __spex_rc=$?`,
|
|
893
977
|
` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
|
|
894
978
|
` printf '[spex launch] attempt %s exited in %ss (rc=%s) - fast launcher exit before readiness; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
@@ -1003,7 +1087,7 @@ export function superviseQueue(intervalMs = 3000): void {
|
|
|
1003
1087
|
// the backend it answers acts on ITS OWN mainRoot, so a stale inherited SPEXCODE_API_URL (pointing at
|
|
1004
1088
|
// another repo's backend) silently lands the write in the WRONG repo. Read/control-READS deliberately
|
|
1005
1089
|
// point anywhere (viewer-points-anywhere, see remote-client); every MUTATING verb (new/merge/send/close/
|
|
1006
|
-
// rename/
|
|
1090
|
+
// rename/input/resume/stop) is bound to the caller's project. So before writing, compare the caller's
|
|
1007
1091
|
// repo root to the backend's served root and FAIL LOUD on a provable, same-host mismatch — never a silent
|
|
1008
1092
|
// misroute. An explicit `--api`/`--port` flag SKIPS the guard: the flag is the one provably-deliberate
|
|
1009
1093
|
// cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
|
|
@@ -1016,8 +1100,8 @@ export async function assertProjectMatch(verb: string): Promise<void> {
|
|
|
1016
1100
|
try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
|
|
1017
1101
|
let served: string | null = null
|
|
1018
1102
|
try {
|
|
1019
|
-
const r = await fetch(`${url}/api/
|
|
1020
|
-
if (r.ok) served = (await r.json() as { main?: string }).main ?? null
|
|
1103
|
+
const r = await fetch(`${url}/api/settings`)
|
|
1104
|
+
if (r.ok) served = (await r.json() as { layout?: { main?: string } }).layout?.main ?? null
|
|
1021
1105
|
} catch { return } // backend unreachable → the write itself surfaces it (fail-loud there)
|
|
1022
1106
|
if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
|
|
1023
1107
|
let backendMain: string
|
|
@@ -1032,19 +1116,19 @@ export async function assertProjectMatch(verb: string): Promise<void> {
|
|
|
1032
1116
|
}
|
|
1033
1117
|
}
|
|
1034
1118
|
|
|
1035
|
-
// @@@ createSession (dispatch via backend) - `spex
|
|
1119
|
+
// @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
|
|
1036
1120
|
// BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
|
|
1037
|
-
// launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex new` (e.g. a supervisor) would
|
|
1121
|
+
// launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
|
|
1038
1122
|
// bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore — it
|
|
1039
1123
|
// comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
|
|
1040
1124
|
// either process.) So the CLI POSTs to the running backend whenever one answers. Only when NO backend is
|
|
1041
1125
|
// reachable do we fall back to launching in this process (with a stderr warning) — the backend's own POST
|
|
1042
1126
|
// handler calls newSession directly, so it never re-enters this path.
|
|
1043
1127
|
export async function createSession(node: string | null, prompt: string, launcher?: string): Promise<Session> {
|
|
1044
|
-
await assertProjectMatch('spex new')
|
|
1128
|
+
await assertProjectMatch('spex session new')
|
|
1045
1129
|
// @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
|
|
1046
1130
|
// via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
|
|
1047
|
-
// backend, whose process env carries no acting session id. An agent that runs `spex new` stamps its own id;
|
|
1131
|
+
// backend, whose process env carries no acting session id. An agent that runs `spex session new` stamps its own id;
|
|
1048
1132
|
// a human in a plain shell has none → null → the new session is top-level (no phantom nesting).
|
|
1049
1133
|
const parent = ownSessionId()
|
|
1050
1134
|
let res: Response
|
|
@@ -1168,7 +1252,7 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
|
|
|
1168
1252
|
// genuinely dead/unrecoverable agent never goes online, so after the timeout we return and the caller's own
|
|
1169
1253
|
// deliver() fails loud exactly as before — this only closes the startup race, it adds no fallback.
|
|
1170
1254
|
const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fail relaunch window, so
|
|
1171
|
-
// waitForReady (slot-hold +
|
|
1255
|
+
// waitForReady (slot-hold + resume) waits through a daemon-race retry
|
|
1172
1256
|
// instead of returning before a recovering socket
|
|
1173
1257
|
const SOCKET_POLL_MS = 200
|
|
1174
1258
|
async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<boolean> {
|
|
@@ -1182,11 +1266,11 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
|
|
|
1182
1266
|
}
|
|
1183
1267
|
}
|
|
1184
1268
|
|
|
1185
|
-
// @@@
|
|
1269
|
+
// @@@ resumeSession - bring the agent back up and settle its RESTING lifecycle. THREE rules:
|
|
1186
1270
|
// • RESUME GUARD ([[state]]): a relaunch KILLS the running agent (`kill-session` + fresh window), so it is a
|
|
1187
1271
|
// data-loss operation the moment the agent is actually ALIVE — the incident's kill-shot was restore-on-alive
|
|
1188
|
-
// (the board LIED offline, the human relaunched, live workers died mid-work). So
|
|
1189
|
-
// agent's liveness FRESH and, when the caller is guarding (the human relaunch panel / `spex session
|
|
1272
|
+
// (the board LIED offline, the human relaunched, live workers died mid-work). So resume re-derives the
|
|
1273
|
+
// agent's liveness FRESH and, when the caller is guarding (the human relaunch panel / `spex session resume`,
|
|
1190
1274
|
// `guard` default true), REFUSES LOUD rather than relaunch a live agent — you steer a live agent by
|
|
1191
1275
|
// MESSAGING it, not by restoring it. Death must be PROVEN: an `unknown` probe (tmux timed out under load —
|
|
1192
1276
|
// the exact condition that started the incident) also refuses, since a live worker can't be ruled out. A
|
|
@@ -1200,11 +1284,11 @@ async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_REA
|
|
|
1200
1284
|
// immediately after (mergeSession's merge) addresses a LIVE agent, not a racing boot.
|
|
1201
1285
|
// • lifecycle: the SAME active-only guard markIdle uses — a resumed agent that was WORKING (`active`) is now
|
|
1202
1286
|
// just sitting at its prompt → `idle`; EVERY deliberate declaration survives untouched (`awaiting` + its
|
|
1203
|
-
// proposal, `asking`, `parked`, `error`, `queued`).
|
|
1287
|
+
// proposal, `asking`, `parked`, `error`, `queued`). resume does NOT touch the `proposal` — resuming a
|
|
1204
1288
|
// session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
|
|
1205
1289
|
// a refusal leaves the record wholly untouched.
|
|
1206
1290
|
// Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
|
|
1207
|
-
export async function
|
|
1291
|
+
export async function resumeSession(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
|
|
1208
1292
|
const { force = false, guard = true } = opts
|
|
1209
1293
|
const wt = await findWorktree(id)
|
|
1210
1294
|
if (!wt) return { ok: false, error: `no such session ${id}` }
|
|
@@ -1312,7 +1396,7 @@ function porcelainPath(line: string): string {
|
|
|
1312
1396
|
// whether uncommitted non-runtime work remains, the merge/lint gates, and the agent's standing proposal.
|
|
1313
1397
|
// ahead/dirty/diff/conflicts are computed against the SESSION's worktree (per id); lint reflects the CLI
|
|
1314
1398
|
// package's OWN location (where this runs) — the spec-cli that's actually live. There is deliberately NO
|
|
1315
|
-
// build/typecheck/test gate: whether a change is SOUND is proven by the node's
|
|
1399
|
+
// build/typecheck/test gate: whether a change is SOUND is proven by the node's eval scenarios (measured through the
|
|
1316
1400
|
// real product), not by a language-specific automated checker — so the gates stay language-agnostic (git +
|
|
1317
1401
|
// the spec↔code graph, which every governed project has, TS or Python or otherwise). null when no session
|
|
1318
1402
|
// has that id.
|
|
@@ -1332,7 +1416,7 @@ export type ReviewPayload = {
|
|
|
1332
1416
|
|
|
1333
1417
|
// @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
|
|
1334
1418
|
// (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
|
|
1335
|
-
// it on every reviewPayload — i.e. on every [[
|
|
1419
|
+
// it on every reviewPayload — i.e. on every [[session-eval]] Proof-tab open, and once per session — is
|
|
1336
1420
|
// wasteful, so memoize it on a whole-repo fingerprint: `rev-parse HEAD` + `status --porcelain` + the mtimes of
|
|
1337
1421
|
// the changed paths (covers committed state, the dirty SET, and dirty-file CONTENT). An identical fingerprint
|
|
1338
1422
|
// reuses the last (in-flight) result — a re-open or a second session's proof is instant — while any commit or
|
|
@@ -1415,10 +1499,10 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
|
|
|
1415
1499
|
|
|
1416
1500
|
// @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
|
|
1417
1501
|
// SESSION'S OWN agent lands the merge, never the server (it carries no `git merge` logic and never touches
|
|
1418
|
-
// main's tree). It
|
|
1502
|
+
// main's tree). It resumes the session (clears the proposal, `--resume`s via resumeSession if tmux died —
|
|
1419
1503
|
// which waits for the rendezvous socket, closing the just-relaunched-no-socket race) and dispatches mergePrompt
|
|
1420
|
-
// — that delivered prompt flips the lifecycle to active regardless of
|
|
1421
|
-
// through
|
|
1504
|
+
// — that delivered prompt flips the lifecycle to active regardless of resume's resting state
|
|
1505
|
+
// through sendText. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
|
|
1422
1506
|
// the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
|
|
1423
1507
|
// CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
|
|
1424
1508
|
// re-checks gates, runs git, bumps `merges`, or closes the session — review shows the gates; the agent verifies.
|
|
@@ -1428,16 +1512,16 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
|
|
|
1428
1512
|
const branch = wt.branch, main = mainRoot()
|
|
1429
1513
|
// ensure-live, NOT the guarded human relaunch: an already-online agent is reused (the merge prompt just needs
|
|
1430
1514
|
// a live socket), and only a confirmed-offline one is relaunched — so merge never refuses on a live agent.
|
|
1431
|
-
const re = await
|
|
1432
|
-
if (!re.ok) return { dispatched: false, reason: re.error || 'could not
|
|
1515
|
+
const re = await resumeSession(id, { guard: false })
|
|
1516
|
+
if (!re.ok) return { dispatched: false, reason: re.error || 'could not resume session' }
|
|
1433
1517
|
const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', branch])).trim()
|
|
1434
1518
|
const reason = subject.replace(/^spec:\s+/, '') || branch
|
|
1435
|
-
const r = await
|
|
1519
|
+
const r = await sendText(id, mergePrompt(main, branch, reason))
|
|
1436
1520
|
if (!r.ok) return { dispatched: false, reason: r.error }
|
|
1437
1521
|
return { dispatched: true }
|
|
1438
1522
|
}
|
|
1439
1523
|
|
|
1440
|
-
// @@@ stopAgentProcess - the shared teardown both
|
|
1524
|
+
// @@@ stopAgentProcess - the shared teardown both stop and close begin with, so there is ONE kill path, not
|
|
1441
1525
|
// two: kill the agent's tmux client, drop its boot-window stamp (else a just-launched id lingers in the grace
|
|
1442
1526
|
// window reading `starting` instead of `offline`), and sweep its rendezvous socket. The socket lives in the OS
|
|
1443
1527
|
// tmpdir (NOT the worktree), so worktree removal alone would leave it behind — closing many sessions over time
|
|
@@ -1449,22 +1533,22 @@ async function stopAgentProcess(id: string): Promise<void> {
|
|
|
1449
1533
|
try { rmSync(rvSock(id), { force: true }) } catch { /* best-effort sweep; tmpdir socket, claude/OS may already be gone */ }
|
|
1450
1534
|
}
|
|
1451
1535
|
|
|
1452
|
-
// @@@
|
|
1536
|
+
// @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
|
|
1453
1537
|
// worktree + branch + transcript intact. The session stays on the board, now reading `offline` (no tmux window)
|
|
1454
|
-
// whatever its lifecycle, so the relaunch panel offers to --resume the SAME conversation (see
|
|
1538
|
+
// whatever its lifecycle, so the relaunch panel offers to --resume the SAME conversation (see resumeSession). This is
|
|
1455
1539
|
// "step away, come back later"; closeSession is "discard this work". An offline session occupies no slot, so
|
|
1456
1540
|
// the freed capacity drains a queued session next (drainQueue).
|
|
1457
|
-
export async function
|
|
1541
|
+
export async function stopSession(id: string): Promise<boolean> {
|
|
1458
1542
|
const wt = await findWorktree(id)
|
|
1459
1543
|
await stopAgentProcess(id)
|
|
1460
|
-
void drainQueue() //
|
|
1544
|
+
void drainQueue() // a stop frees a slot — start the next queued session if any
|
|
1461
1545
|
return !!wt
|
|
1462
1546
|
}
|
|
1463
1547
|
|
|
1464
|
-
// @@@ closeSession - the REMOVAL (human-confirmed):
|
|
1548
|
+
// @@@ closeSession - the REMOVAL (human-confirmed): stop's soft kill PLUS removing the worktree + branch AND
|
|
1465
1549
|
// the session's whole global-store record dir — the work is gone, not just stopped. Same stop primitive as
|
|
1466
|
-
//
|
|
1467
|
-
// then the store sweep (
|
|
1550
|
+
// stopSession (no duplicate kill path), then the git worktree/branch teardown that stop deliberately skips,
|
|
1551
|
+
// then the store sweep (stop KEEPS the record so the session stays on the board offline; close discards it).
|
|
1468
1552
|
// The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
|
|
1469
1553
|
// so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
|
|
1470
1554
|
export async function closeSession(id: string): Promise<boolean> {
|
|
@@ -1483,7 +1567,7 @@ export async function closeSession(id: string): Promise<boolean> {
|
|
|
1483
1567
|
}
|
|
1484
1568
|
|
|
1485
1569
|
// @@@ captureSessionResult - the session's live pane as a one-shot snapshot (output), the server side of
|
|
1486
|
-
// `GET /api/sessions/:id/capture` that `spex capture` (a backend client) reads. A monitoring read MUST
|
|
1570
|
+
// `GET /api/sessions/:id/capture` that `spex session show --capture` (a backend client) reads. A monitoring read MUST
|
|
1487
1571
|
// distinguish "I failed to read" from "the pane is genuinely empty" — the old captureSession collapsed
|
|
1488
1572
|
// unknown-id, offline, and capture-error all to `''`, indistinguishable from an empty pane (a blank screen
|
|
1489
1573
|
// that exits 0 is worse than useless to a manager). So the result is DISCRIMINATED: an empty pane is a
|
|
@@ -1507,8 +1591,8 @@ export async function captureSessionResult(id: string): Promise<CaptureResult> {
|
|
|
1507
1591
|
// transition — review / done / close-pending (agent proposals), offline (process died), error — and the
|
|
1508
1592
|
// removal. Per Monitor's "silence is not success" rule a vanished session pings too. Net feed:
|
|
1509
1593
|
// launched → [actionable transitions] → closed. Each line names the suggested next action(s). Drop into Monitor:
|
|
1510
|
-
// Monitor({ command: 'spex watch', persistent: true, description: '
|
|
1511
|
-
// @@@ presentation + selection - shared by `spex ls` (pretty), `spex watch` (events) and the API.
|
|
1594
|
+
// Monitor({ command: 'spex session watch', persistent: true, description: 'session state changes' })
|
|
1595
|
+
// @@@ presentation + selection - shared by `spex session ls` (pretty), `spex session watch` (events) and the API.
|
|
1512
1596
|
export const STATUS_GLYPH: Record<DisplayStatus, string> = {
|
|
1513
1597
|
working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
|
|
1514
1598
|
'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc', unknown: '\u2047',
|
|
@@ -1543,7 +1627,7 @@ export function selectSessions(all: Session[], selectors: string[], statuses?: s
|
|
|
1543
1627
|
}
|
|
1544
1628
|
|
|
1545
1629
|
// @@@ resolveSession - resolve ONE selector to ONE session against a board: the single-target counterpart of
|
|
1546
|
-
// selectSessions, for the control verbs (review/send/merge/close/
|
|
1630
|
+
// selectSessions, for the control verbs (review/send/merge/close/resume/show). The backend matches
|
|
1547
1631
|
// ids EXACTLY, so a verb resolves the selector here first and then calls with the FULL id — a node/branch/
|
|
1548
1632
|
// prefix selector drives a verb just as it filters `ls`. The result is DISCRIMINATED so a caller can fail
|
|
1549
1633
|
// precisely: an exact full-id hit wins outright (never reported ambiguous just for prefixing a longer id);
|
|
@@ -1600,7 +1684,7 @@ export const NOTE_BOARD_LIMIT = 50
|
|
|
1600
1684
|
const SHORT: Partial<Record<DisplayStatus, string>> = { 'close-pending': 'close' }
|
|
1601
1685
|
|
|
1602
1686
|
// @@@ statusLegend - one-line glyph\u2192meaning key, BUILT from STATUS_GLYPH so it can never drift from
|
|
1603
|
-
// the glyphs the table actually prints. Shown under `spex ls` so the symbols are self-explanatory.
|
|
1687
|
+
// the glyphs the table actually prints. Shown under `spex session ls` so the symbols are self-explanatory.
|
|
1604
1688
|
export function statusLegend(color = true): string {
|
|
1605
1689
|
const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
|
|
1606
1690
|
const parts = (Object.keys(STATUS_GLYPH) as DisplayStatus[]).map(
|
|
@@ -1633,10 +1717,10 @@ const NEXT: Record<string, string> = {
|
|
|
1633
1717
|
review: 'merge | close',
|
|
1634
1718
|
done: 'merge | close',
|
|
1635
1719
|
'close-pending': 'close',
|
|
1636
|
-
offline: '
|
|
1637
|
-
error: '
|
|
1638
|
-
asking: 'send "<msg>" | capture',
|
|
1639
|
-
idle: 'send "<msg>" | capture',
|
|
1720
|
+
offline: 'resume (relaunch the same conversation)',
|
|
1721
|
+
error: 'resume (relaunch & retry) | show --capture | close',
|
|
1722
|
+
asking: 'send "<msg>" | show --capture',
|
|
1723
|
+
idle: 'send "<msg>" | show --capture',
|
|
1640
1724
|
queued: 'waiting for a free slot — starts automatically | close',
|
|
1641
1725
|
}
|
|
1642
1726
|
export function sessionEvent(s: Session): string {
|
|
@@ -1646,32 +1730,41 @@ export function sessionEvent(s: Session): string {
|
|
|
1646
1730
|
}
|
|
1647
1731
|
// @@@ launchEvent - a session's FIRST sighting. A launch goes straight to 'working' (not actionable), so
|
|
1648
1732
|
// without this the watch feed would be blind to new sessions starting. Emitted ONCE per id, regardless of
|
|
1649
|
-
// status, so `spex watch` is a complete lifecycle feed: launched → [actionable transitions] → closed.
|
|
1733
|
+
// status, so `spex session watch` is a complete lifecycle feed: launched → [actionable transitions] → closed.
|
|
1650
1734
|
export function launchEvent(s: Session): string {
|
|
1651
1735
|
const note = s.note ? ` — note: ${s.note}` : ''
|
|
1652
1736
|
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
1653
1737
|
return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
|
|
1654
1738
|
}
|
|
1655
1739
|
// @@@ source - the session board the poll reads. The CLI passes the BACKEND CLIENT (client.ts
|
|
1656
|
-
// clientListSessions), so `spex watch` streams whatever backend SPEXCODE_API_URL points at — including a
|
|
1740
|
+
// clientListSessions), so `spex session watch` streams whatever backend SPEXCODE_API_URL points at — including a
|
|
1657
1741
|
// REMOTE machine's. It is REQUIRED (no local default): a forgotten source must be a compile error, never a
|
|
1658
1742
|
// silent in-process read of the wrong (local) board — the exact false-green the 2-machine test guards.
|
|
1659
|
-
export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number } }
|
|
1660
|
-
// @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex wait` runs on); a
|
|
1743
|
+
export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number; onObserved?: (status: DisplayStatus, previous: DisplayStatus | null) => void } }
|
|
1744
|
+
// @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex session wait` runs on); a
|
|
1661
1745
|
// plain watch (no `until`) streams forever and never resolves. The bound is what makes `wait` a one-shot
|
|
1662
|
-
// "block for a worker, then exit" that is GUARANTEED to return.
|
|
1663
|
-
//
|
|
1664
|
-
//
|
|
1665
|
-
// `
|
|
1746
|
+
// "block for a worker's NEXT transition, then exit" that is GUARANTEED to return. Bounded mode is
|
|
1747
|
+
// EDGE-TRIGGERED ([[session-edges]]): it resolves only upon OBSERVING a watched target transition from a
|
|
1748
|
+
// non-actionable status INTO an actionable one — an already-actionable first sighting is recorded and
|
|
1749
|
+
// narrated (`onObserved`, arrival first) but never resolves, so "wait for the dispatched merge to actually
|
|
1750
|
+
// land" has a real signal instead of an instant false return on the standing `review` level. Every observed
|
|
1751
|
+
// status per target accumulates into a `path` (arrival first); the resolving target's path rides the outcome
|
|
1752
|
+
// so the caller can print the whole observed sequence. The deadline is checked EVERY poll, before
|
|
1753
|
+
// EVERY sleep (and even when a poll throws), so a target that never produces an edge — stuck in ANY
|
|
1754
|
+
// non-actionable state (`working`/`parked`/`idle`/`queued`/`starting`), or sitting forever on an
|
|
1755
|
+
// already-actionable level — can never hang the caller: it exits at the deadline, path carried for the report.
|
|
1756
|
+
// `reached` = an observed edge into an actionable status; the rest are the loud exits. `backendDown` is a verdict
|
|
1666
1757
|
// about the TRANSPORT, never the session — `kind` keeps its two shapes distinct for the caller's outcome
|
|
1667
1758
|
// surface: 'unreachable' (nothing listening, the whole timeout was spent retrying) vs 'http' (reachable but
|
|
1668
1759
|
// broken, failed loud at once). The caller must surface these OUTSIDE the session-status vocabulary — a
|
|
1669
1760
|
// supervisor must never be able to read a transport failure as a session state (issue #40).
|
|
1670
|
-
export type WatchOutcome = { reached: DisplayStatus } | { timedOut: true } | { gone: true } | { backendDown: string; kind: 'unreachable' | 'http' }
|
|
1761
|
+
export type WatchOutcome = { reached: DisplayStatus; path: DisplayStatus[] } | { timedOut: true; path: DisplayStatus[] } | { gone: true; path: DisplayStatus[] } | { backendDown: string; kind: 'unreachable' | 'http' }
|
|
1671
1762
|
export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
|
|
1672
1763
|
const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
|
|
1673
1764
|
const tag = as ? `[${as}] ` : ''
|
|
1674
1765
|
const prev = new Map<string, DisplayStatus>()
|
|
1766
|
+
const paths = new Map<string, DisplayStatus[]>() // bounded mode: every status observed per target, arrival first
|
|
1767
|
+
const anyPath = () => (paths.size ? [...paths.values()][0] : [])
|
|
1675
1768
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
1676
1769
|
// the no-hang wall: a fixed deadline computed ONCE, checked unconditionally every iteration below.
|
|
1677
1770
|
const deadline = until ? Date.now() + Math.max(1000, until.timeoutMs) : 0
|
|
@@ -1689,10 +1782,23 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1689
1782
|
warnedDown = false; downMsg = null // a successful poll re-arms the down-warning (and clears the deadline's down-report)
|
|
1690
1783
|
const ids = new Set(all.map((s) => s.id))
|
|
1691
1784
|
const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
|
|
1785
|
+
let edge: { status: DisplayStatus; path: DisplayStatus[] } | null = null
|
|
1692
1786
|
for (const s of all) {
|
|
1693
|
-
|
|
1694
|
-
if (
|
|
1787
|
+
const was = prev.has(s.id) ? prev.get(s.id)! : null
|
|
1788
|
+
if (was === null) emit(tag + launchEvent(s)) // FIRST sighting → launched, any status (incl. 'working'), once
|
|
1789
|
+
if (s.status === was) continue // only on transition, not every tick
|
|
1695
1790
|
prev.set(s.id, s.status)
|
|
1791
|
+
if (until) {
|
|
1792
|
+
const p = paths.get(s.id) ?? []
|
|
1793
|
+
p.push(s.status)
|
|
1794
|
+
paths.set(s.id, p)
|
|
1795
|
+
until.onObserved?.(s.status, was)
|
|
1796
|
+
// THE edge: a previously-observed NON-actionable status transitioning INTO an actionable one. An
|
|
1797
|
+
// actionable ARRIVAL (was === null) is deliberately not an edge — that standing level is what the
|
|
1798
|
+
// old wait false-returned on; and an actionable→actionable hop (review→done) isn't one either. The
|
|
1799
|
+
// rise out of non-actionable is the one signal that means "the target needs you AGAIN".
|
|
1800
|
+
if (was !== null && !isActionable(was) && isActionable(s.status)) edge = { status: s.status, path: p }
|
|
1801
|
+
}
|
|
1696
1802
|
if (passesStatus(s.status) && (WATCH_ACTIONABLE.has(s.status) || (includeIdle && s.status === 'idle'))) emit(tag + sessionEvent(s))
|
|
1697
1803
|
}
|
|
1698
1804
|
// @@@ closed = the worktree is GONE. Because listSessions lists every EXISTING worktree (a flaky detail
|
|
@@ -1704,13 +1810,13 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1704
1810
|
prev.delete(id)
|
|
1705
1811
|
emit(`${tag}[spex] closed \u00b7 removed [id ${id}]`)
|
|
1706
1812
|
}
|
|
1707
|
-
// BOUNDED mode (`until`, what `spex wait` runs): return
|
|
1708
|
-
// means the target is gone (absent from the board), which it can never come
|
|
1709
|
-
// the try, after the emit pass, so the caller still saw every transition
|
|
1813
|
+
// BOUNDED mode (`until`, what `spex session wait` runs): return on an OBSERVED non-actionable→actionable
|
|
1814
|
+
// edge; an empty selected set means the target is gone (absent from the board), which it can never come
|
|
1815
|
+
// back from. Both sit inside the try, after the emit pass, so the caller still saw every transition
|
|
1816
|
+
// before we hand control back.
|
|
1710
1817
|
if (until) {
|
|
1711
|
-
|
|
1712
|
-
if (
|
|
1713
|
-
if (!all.length) return { gone: true }
|
|
1818
|
+
if (edge) return { reached: edge.status, path: edge.path }
|
|
1819
|
+
if (!all.length) return { gone: true, path: anyPath() }
|
|
1714
1820
|
}
|
|
1715
1821
|
} catch (e) {
|
|
1716
1822
|
// a backend error in the poll must NOT be swallowed AND must NOT emit a false `closed` for every session:
|
|
@@ -1720,7 +1826,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1720
1826
|
// • UNREACHABLE (no status — ECONNREFUSED / fetch failed) — nothing is listening, e.g. the supervisor
|
|
1721
1827
|
// is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
|
|
1722
1828
|
// record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
|
|
1723
|
-
// backgrounded `spex wait` survives the ~1s restart instead of dying on the interrupted fetch.
|
|
1829
|
+
// backgrounded `spex session wait` survives the ~1s restart instead of dying on the interrupted fetch.
|
|
1724
1830
|
if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message, kind: 'http' }
|
|
1725
1831
|
if (isBackendDown(e)) {
|
|
1726
1832
|
downMsg = (e as Error).message
|
|
@@ -1728,22 +1834,22 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1728
1834
|
}
|
|
1729
1835
|
}
|
|
1730
1836
|
// the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep: this
|
|
1731
|
-
// guarantees `spex wait` can never hang on a worker
|
|
1837
|
+
// guarantees `spex session wait` can never hang on a worker that never produces an edge — nor spin forever on a
|
|
1732
1838
|
// backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
|
|
1733
|
-
// not a false "no
|
|
1734
|
-
if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg, kind: 'unreachable' } : { timedOut: true }
|
|
1839
|
+
// not a false "no edge" timeout, so the manager sees the honest cause.
|
|
1840
|
+
if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg, kind: 'unreachable' } : { timedOut: true, path: anyPath() }
|
|
1735
1841
|
await sleep(intervalMs)
|
|
1736
1842
|
}
|
|
1737
1843
|
}
|
|
1738
1844
|
|
|
1739
|
-
// @@@
|
|
1845
|
+
// @@@ sendText - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
|
|
1740
1846
|
// ([[harness-adapter]]) — claude the rendezvous control socket (optimistic-after-liveness: the reply line flushes
|
|
1741
1847
|
// to a live socket), codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
|
|
1742
1848
|
// fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
|
|
1743
1849
|
// ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
|
|
1744
1850
|
// instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
|
|
1745
1851
|
// harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
1746
|
-
export async function
|
|
1852
|
+
export async function sendText(id: string, text: string, from?: string): Promise<DispatchResult> {
|
|
1747
1853
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
1748
1854
|
const rec = readRecord(id)
|
|
1749
1855
|
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|