spexcode 0.1.6 → 0.2.1
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 +99 -35
- package/README.zh-CN.md +135 -0
- package/package.json +5 -6
- package/spec-cli/README.md +86 -0
- package/spec-cli/bin/spex.mjs +15 -3
- package/spec-cli/hooks/dispatch.sh +20 -8
- package/spec-cli/hooks/harness.sh +18 -11
- package/spec-cli/src/board.ts +47 -18
- package/spec-cli/src/boardCache.ts +70 -0
- package/spec-cli/src/boardDelta.ts +90 -0
- package/spec-cli/src/boardStream.ts +178 -0
- package/spec-cli/src/cli.ts +184 -122
- package/spec-cli/src/client.ts +6 -4
- package/spec-cli/src/gateway.ts +64 -24
- package/spec-cli/src/git.ts +105 -92
- package/spec-cli/src/guide.ts +186 -19
- package/spec-cli/src/harness-select.ts +63 -0
- package/spec-cli/src/harness.ts +506 -100
- package/spec-cli/src/help.ts +362 -0
- package/spec-cli/src/hooks.ts +0 -14
- package/spec-cli/src/index.ts +279 -32
- package/spec-cli/src/init.ts +41 -1
- package/spec-cli/src/issues.ts +301 -0
- package/spec-cli/src/layout.ts +70 -28
- package/spec-cli/src/lint.ts +12 -10
- package/spec-cli/src/listen.ts +28 -0
- package/spec-cli/src/localIssues.ts +700 -0
- package/spec-cli/src/materialize.ts +182 -27
- package/spec-cli/src/mentions.ts +192 -0
- package/spec-cli/src/plugin-harness.ts +145 -0
- package/spec-cli/src/pty-bridge.ts +378 -81
- package/spec-cli/src/self.ts +123 -20
- package/spec-cli/src/sessions.ts +461 -298
- package/spec-cli/src/specs.ts +55 -14
- package/spec-cli/src/supervise.ts +23 -3
- package/spec-cli/src/tsx-bin.ts +14 -5
- package/spec-cli/src/uninstall.ts +146 -0
- package/spec-cli/templates/hooks/post-merge +27 -0
- package/spec-cli/templates/hooks/pre-commit +51 -31
- package/spec-cli/templates/hooks/prepare-commit-msg +31 -8
- package/spec-cli/templates/presets/careful/.config/clarify-before-code/spec.md +11 -0
- package/spec-cli/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +26 -3
- package/spec-cli/templates/spec/project/.config/core/spec.md +4 -0
- package/spec-cli/templates/spec/project/.config/core/stop-gate/stop-gate.sh +16 -4
- package/spec-cli/templates/spec/project/.config/extract/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/regroup/spec.md +1 -1
- package/spec-cli/templates/spec/project/.config/reproduce-before-fix/spec.md +18 -0
- package/spec-cli/templates/spec/project/.config/spec.md +3 -3
- package/spec-cli/templates/spec/project/.config/supervisor/spec.md +2 -2
- package/spec-cli/templates/spec/project/.config/tidy/spec.md +1 -1
- package/spec-cli/templates/spec/project/spec.md +2 -2
- package/spec-dashboard/dist/assets/index-Ct_ubwrd.css +32 -0
- package/spec-dashboard/dist/assets/index-DehTZ-h9.js +145 -0
- package/spec-dashboard/dist/index.html +17 -5
- package/spec-forge/src/cache.ts +16 -0
- package/spec-forge/src/cli.ts +4 -10
- package/spec-forge/src/drivers/github.ts +74 -4
- package/spec-forge/src/drivers.ts +13 -0
- package/spec-forge/src/port.ts +25 -0
- package/spec-forge/src/resident.ts +40 -6
- package/spec-yatsu/src/cli.ts +227 -38
- package/spec-yatsu/src/evaltab.ts +169 -19
- package/spec-yatsu/src/filing.ts +48 -0
- package/spec-yatsu/src/freshness.ts +55 -20
- package/spec-yatsu/src/proof.ts +89 -3
- package/spec-yatsu/src/scenariofresh.ts +92 -0
- package/spec-yatsu/src/sidecar.ts +75 -11
- package/spec-yatsu/src/timeline.ts +47 -0
- package/spec-yatsu/src/yatsu.ts +47 -3
- package/spec-cli/src/relay.ts +0 -28
- package/spec-cli/templates/spec/project/.config/scenario/spec.md +0 -32
- package/spec-dashboard/dist/assets/index-Bk4E1EQy.js +0 -139
- package/spec-dashboard/dist/assets/index-Cq7hwngj.css +0 -32
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process'
|
|
2
2
|
import { promisify } from 'node:util'
|
|
3
3
|
import { randomUUID } from 'node:crypto'
|
|
4
|
-
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync } from 'node:fs'
|
|
5
|
-
import { join, dirname, relative } from 'node:path'
|
|
4
|
+
import { readFileSync, writeFileSync, appendFileSync, existsSync, renameSync, mkdirSync, rmSync, readdirSync, realpathSync, statSync } from 'node:fs'
|
|
5
|
+
import { join, dirname, relative, isAbsolute } from 'node:path'
|
|
6
6
|
import { fileURLToPath } from 'node:url'
|
|
7
7
|
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
|
|
8
8
|
import { loadSpecs } from './specs.js'
|
|
9
|
-
import { defaultHarness, harnessById, rvSock, type Harness, type DispatchResult } from './harness.js'
|
|
9
|
+
import { defaultHarness, harnessById, resolveLauncher, rvSock, rendezvousListening, type Harness, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
10
10
|
import { materialize } from './materialize.js'
|
|
11
11
|
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
12
12
|
|
|
@@ -21,9 +21,11 @@ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, ses
|
|
|
21
21
|
// lifecycle (authored): active | idle | awaiting | parked | error | asking | queued. `idle` is the ONE
|
|
22
22
|
// inferred one (the Notification(idle_prompt) hook, guarded active-only so it never clobbers a
|
|
23
23
|
// declaration; mark-active flips it back to active on real work).
|
|
24
|
-
// liveness (derived for EVERY session): online | starting | offline. offline = no tmux for the id,
|
|
25
|
-
// harness online-signal (claude's rendezvous socket
|
|
26
|
-
// the boot
|
|
24
|
+
// liveness (derived for EVERY session): online | starting | offline | unknown. offline = no tmux for the id,
|
|
25
|
+
// or the harness online-signal (claude's rendezvous socket LISTENER — a connect, not the socket
|
|
26
|
+
// FILE) is gone past the boot grace; starting = the boot window; unknown = the tmux probe itself
|
|
27
|
+
// failed (timed out under load) so death is UNPROVEN — render probe-failed, never offline/vanish.
|
|
28
|
+
// reconcile composes the two into the compact DisplayStatus for one-glyph surfaces.
|
|
27
29
|
// awaiting → the agent's PROPOSAL, awaiting a human:
|
|
28
30
|
// proposal=merge → shown "review" ("ready, merge me")
|
|
29
31
|
// proposal=nothing → shown "done" ("finished, your call")
|
|
@@ -35,8 +37,9 @@ import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, ses
|
|
|
35
37
|
// schedule and self-resumes); an asking agent resumes only when a human sends it a prompt.
|
|
36
38
|
// queued → a prepared worktree held below the concurrency cap; the drainer launches it as a slot frees.
|
|
37
39
|
// (closed = the worktree AND the global record are removed; not a stored status)
|
|
38
|
-
// The agent only ever PROPOSES (awaiting); merge/close are human-only. Every proposal is reversible
|
|
39
|
-
//
|
|
40
|
+
// The agent only ever PROPOSES (awaiting); merge/close are human-only. Every proposal is reversible — nothing
|
|
41
|
+
// auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (reopen)
|
|
42
|
+
// deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
|
|
40
43
|
//
|
|
41
44
|
// Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
|
|
42
45
|
// SPEXCODE_TMUX / SPEXCODE_CLAUDE_CMD override both for tests.
|
|
@@ -53,8 +56,9 @@ const COLS = 120, ROWS = 32
|
|
|
53
56
|
// cap is QUEUED, not started: it becomes a durable `queued` worktree that the drainer launches the moment a
|
|
54
57
|
// slot frees (an agent stops working/dies). NOT hardcoded — configured PER PROJECT in `spexcode.json`
|
|
55
58
|
// (`sessions.maxActive`), so a box can be tuned to its capacity without touching the toolchain. Precedence:
|
|
56
|
-
// spexcode.json → `SPEXCODE_MAX_ACTIVE` env → default
|
|
59
|
+
// spexcode.json → `SPEXCODE_MAX_ACTIVE` env → default 8. Read LIVE (cheap file read) so an edit takes effect
|
|
57
60
|
// on the next drain tick, no restart. Floored at 1 so a bad value can't wedge the queue to 0.
|
|
61
|
+
const DEFAULT_MAX_ACTIVE = 8
|
|
58
62
|
function maxActive(): number {
|
|
59
63
|
let v: number | undefined
|
|
60
64
|
try {
|
|
@@ -62,7 +66,7 @@ function maxActive(): number {
|
|
|
62
66
|
if (typeof fromJson === 'number' && Number.isFinite(fromJson)) v = fromJson
|
|
63
67
|
} catch { /* config unreadable — fall through to env/default */ }
|
|
64
68
|
if (v === undefined) { const e = Number(process.env.SPEXCODE_MAX_ACTIVE); if (Number.isFinite(e) && e > 0) v = e }
|
|
65
|
-
return Math.max(1, Math.floor(v ??
|
|
69
|
+
return Math.max(1, Math.floor(v ?? DEFAULT_MAX_ACTIVE))
|
|
66
70
|
}
|
|
67
71
|
|
|
68
72
|
// the rendezvous control socket path + its prompt-delivery/liveness logic now live in the [[harness-adapter]]
|
|
@@ -75,10 +79,12 @@ function maxActive(): number {
|
|
|
75
79
|
// an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
|
|
76
80
|
// ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
|
|
77
81
|
const rvEnv = (id: string, harness = HARNESS) => {
|
|
78
|
-
// SPEXCODE_SESSION_ID is the
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
+
// SPEXCODE_SESSION_ID is the governed record id. Claude's harness id is the same value, so hooks and CLI
|
|
83
|
+
// calls can use it directly. Codex cannot trust this env inside the long-lived shared app-server; codex hooks
|
|
84
|
+
// start from the payload thread id and alias through harness_session_id, while the short-lived codex-launch
|
|
85
|
+
// process uses this env only to store the freshly started thread id on the governed record. The CLAUDE_BG
|
|
86
|
+
// rendezvous control socket is the reclaude prompt-delivery path and exists ONLY for harnesses that own one
|
|
87
|
+
// (claude) — codex has no such daemon, so it's omitted there.
|
|
82
88
|
const parts = [`SPEXCODE_SESSION_ID=${id}`]
|
|
83
89
|
if (harness.ownsRendezvous) parts.push(`CLAUDE_BG_BACKEND=daemon`, `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`)
|
|
84
90
|
for (const v of ['SPEXCODE_HOME', 'CODEX_HOME']) { const val = process.env[v]; if (val) parts.push(`${v}=${val}`) }
|
|
@@ -92,16 +98,23 @@ export type { DispatchResult }
|
|
|
92
98
|
|
|
93
99
|
export type Lifecycle = 'active' | 'idle' | 'awaiting' | 'parked' | 'error' | 'asking' | 'queued'
|
|
94
100
|
export type Proposal = 'merge' | 'nothing' | 'close'
|
|
95
|
-
export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued'
|
|
101
|
+
export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued' | 'unknown'
|
|
96
102
|
// liveness — the orthogonal axis to Lifecycle: whether the agent process is actually up, derived (never
|
|
97
103
|
// authored) for EVERY session regardless of its lifecycle. See [[state]]: lifecycle and liveness never
|
|
98
104
|
// override each other; the UI keys the terminal-mount / relaunch panel on this, the badge on lifecycle.
|
|
99
|
-
|
|
105
|
+
// `unknown` = the liveness PROBE ITSELF failed (the tmux snapshot timed out / errored under load), so we
|
|
106
|
+
// CANNOT tell — the row renders probe-failed, NEVER offline/closed and never vanishes (board honesty: a slow
|
|
107
|
+
// box must not masquerade as a graveyard, the failure that drove the mass-restore incident).
|
|
108
|
+
export type Liveness = 'online' | 'starting' | 'offline' | 'unknown'
|
|
100
109
|
const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', nothing: 'done', close: 'close-pending' }
|
|
101
110
|
|
|
102
111
|
export type Session = {
|
|
103
|
-
id: string; node: string | null;
|
|
112
|
+
id: string; node: string | null; branch: string | null; path: string
|
|
113
|
+
label: string; headline: string // the DERIVED display strings ([[session-label]]) — the only names surfaces read
|
|
114
|
+
raw: { name: string | null; title: string | null } // the bare parts, for explicit consumers only (rename prefill)
|
|
115
|
+
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
|
|
104
116
|
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
117
|
+
launcher: string | null // the named launcher profile this session launched under ([[launcher-select]]); null → an unnamed/zero-config launch. Surfaced so a row can badge WHICH launcher (glyph + name) actually brought the worker up
|
|
105
118
|
lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
|
|
106
119
|
prompt: string | null; promptPreview: string | null; created: number; activity: string | null
|
|
107
120
|
sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
|
|
@@ -148,24 +161,40 @@ function promptPreview(prompt: string, n = 60): string {
|
|
|
148
161
|
return first.length > n ? first.slice(0, n - 1) + '…' : first
|
|
149
162
|
}
|
|
150
163
|
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
164
|
+
// @@@ session-label — the ONE place a session's display strings are derived ([[session-label]]). The raw
|
|
165
|
+
// parts (a user rename `name`, the 7-word prompt truncation `title`) never leave this module at the top
|
|
166
|
+
// level: toSession computes `label` (STABLE: name > node > title > branch > id — tables/selectors) and
|
|
167
|
+
// `headline` (LIVE: name > activity > promptPreview > node > title > branch > id — what a human reads,
|
|
168
|
+
// see [[session-activity]]) and the wire carries THOSE; the parts ride only under `raw` for the few
|
|
169
|
+
// explicit consumers (the rename prefill). A surface that wants a session's name reads s.label/s.headline
|
|
170
|
+
// — there is no bare s.title/s.name to reach for, which is the enforcement.
|
|
171
|
+
export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
172
|
+
r.name || r.node || r.title || r.branch || r.id
|
|
173
|
+
export const deriveHeadline = (r: { name?: string | null; activity?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
174
|
+
r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
|
|
175
|
+
|
|
176
|
+
// accessors kept for the human-naming call sites (watch/notify/reply-channel): trivially the precomputed
|
|
177
|
+
// wire fields, so every surface — CLI, dashboard, comms — reads the same derivation by construction.
|
|
178
|
+
export const sessionLabel = (s: Session): string => s.label
|
|
179
|
+
export const sessionHeadline = (s: Session): string => s.headline
|
|
180
|
+
|
|
181
|
+
// @@@ tmux probe timeout - under load (the incident: load ~30 + swap thrash) a bare `tmux list-sessions` can
|
|
182
|
+
// HANG, and with no bound the whole board assembly hung behind it — the dashboard froze / dropped rows, which
|
|
183
|
+
// the human read as "sessions disappeared". So the liveness/title probes pass a bounded timeout; on expiry
|
|
184
|
+
// execFile SIGKILLs the child and rejects with `killed:true`, which liveSnapshot tells apart from a clean
|
|
185
|
+
// "no server" exit (see probeTimedOut) so a timeout renders `unknown`, not a false `offline`.
|
|
186
|
+
const TMUX_PROBE_TIMEOUT_MS = 4000
|
|
187
|
+
async function tmux(args: string[], timeoutMs?: number): Promise<string> {
|
|
188
|
+
const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8', ...(timeoutMs ? { timeout: timeoutMs, killSignal: 'SIGKILL' as const } : {}) })
|
|
167
189
|
return stdout
|
|
168
190
|
}
|
|
191
|
+
// a rejected pexec whose child we KILLED (timeout) vs one that exited cleanly non-zero (e.g. tmux "no server
|
|
192
|
+
// running" when there are genuinely no sessions). Only the former is a PROBE FAILURE (→ unknown); a clean
|
|
193
|
+
// non-zero exit is authoritative (→ everything offline). node sets `killed`/`signal` when it SIGKILLs on timeout.
|
|
194
|
+
function probeTimedOut(e: unknown): boolean {
|
|
195
|
+
const err = e as { killed?: boolean; signal?: string | null; code?: string }
|
|
196
|
+
return err?.killed === true || err?.signal === 'SIGKILL' || err?.code === 'ETIMEDOUT'
|
|
197
|
+
}
|
|
169
198
|
async function tmuxOk(args: string[]): Promise<boolean> { try { await tmux(args); return true } catch { return false } }
|
|
170
199
|
export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-session', '-t', id]) }
|
|
171
200
|
|
|
@@ -189,11 +218,14 @@ function pkgRoot(): string {
|
|
|
189
218
|
// `name` is the rename override (distinct from the prompt-derived `title`); `session` is the harness session_id
|
|
190
219
|
// (the store key). The launcher mints the id (`claude --session-id <id>`) so it equals what every hook payload
|
|
191
220
|
// and CLAUDE_CODE_SESSION_ID carry — one id across the record dir, tmux window, rendezvous socket, and commits.
|
|
192
|
-
type SessRec = {
|
|
221
|
+
export type SessRec = {
|
|
193
222
|
session: string; governed: boolean; worktreePath: string; branch: string | null
|
|
194
223
|
node: string | null; title: string | null; name: string | null
|
|
224
|
+
parent: string | null // the spawning session's id ([[session-nesting]]); null for a top-level launch
|
|
195
225
|
status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
|
|
196
226
|
sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
|
|
227
|
+
launcher: string | null // the named launcher profile this session launches under ([[launcher-select]]); null → the unnamed global default (an old record, or a zero-config launch)
|
|
228
|
+
launchCmd: string | null // the RESOLVED base launcher command pinned at creation ([[launcher-select]] resume-launcher-pin); null → old record → fall back to the launcher name / ambient
|
|
197
229
|
}
|
|
198
230
|
const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
|
|
199
231
|
const PROPOSALS = new Set<Proposal>(['merge', 'nothing', 'close'])
|
|
@@ -214,10 +246,12 @@ function fromRaw(raw: RawRecord): SessRec {
|
|
|
214
246
|
const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
|
|
215
247
|
return {
|
|
216
248
|
session: raw.session_id, governed: !!raw.governed, worktreePath: raw.worktree_path || '', branch: raw.branch || null,
|
|
217
|
-
node: raw.node || null, title: raw.title || null, name: raw.name || null,
|
|
249
|
+
node: raw.node || null, title: raw.title || null, name: raw.name || null, parent: raw.parent || null,
|
|
218
250
|
status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
|
|
219
251
|
harness: raw.harness || 'claude', // records written before the harness field default to claude
|
|
220
252
|
harnessSessionId: raw.harness_session_id || null,
|
|
253
|
+
launcher: raw.launcher || null, // records written before launchers → null → the unnamed global resolution
|
|
254
|
+
launchCmd: raw.launch_cmd || null, // records written before the pin → null → fall back to launcher name / ambient
|
|
221
255
|
}
|
|
222
256
|
}
|
|
223
257
|
// @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
|
|
@@ -233,6 +267,7 @@ function writeRecord(rec: SessRec): void {
|
|
|
233
267
|
node: rec.node ?? '',
|
|
234
268
|
title: rec.title ?? '',
|
|
235
269
|
name: rec.name ?? '',
|
|
270
|
+
parent: rec.parent ?? '',
|
|
236
271
|
status: rec.status,
|
|
237
272
|
proposal: rec.proposal ?? '',
|
|
238
273
|
merges: rec.merges,
|
|
@@ -241,6 +276,8 @@ function writeRecord(rec: SessRec): void {
|
|
|
241
276
|
createdAt: rec.createdAt,
|
|
242
277
|
harness: rec.harness || 'claude',
|
|
243
278
|
harness_session_id: rec.harnessSessionId ?? '',
|
|
279
|
+
launcher: rec.launcher ?? '',
|
|
280
|
+
launch_cmd: rec.launchCmd ?? '',
|
|
244
281
|
}
|
|
245
282
|
mkdirSync(sessionStoreDir(rec.session), { recursive: true })
|
|
246
283
|
writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
|
|
@@ -270,21 +307,59 @@ async function listWorktrees(): Promise<{ path: string; branch: string | null }[
|
|
|
270
307
|
// claude's rendezvous socket is gone (claude exited), else idle if the idle_prompt hook has fired since
|
|
271
308
|
// the last tool use, else working.
|
|
272
309
|
|
|
273
|
-
// @@@ liveTmux - which of OUR tmux sessions exist
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// `
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
// (
|
|
282
|
-
|
|
283
|
-
|
|
310
|
+
// @@@ liveTmux - which of OUR tmux sessions exist AND each pane's runtime probe, in TWO spawns total (one
|
|
311
|
+
// tmux, one ps) for the WHOLE list. reconcile used to spawn two tmux per session (has-session +
|
|
312
|
+
// display-message), so listing N sessions was 2N spawns — the dominant /api/sessions cost under multi-agent
|
|
313
|
+
// load. `tmux list-sessions` returns every session on our socket at once; a session present here has a live
|
|
314
|
+
// tmux window (session_name = the id we created it with), mapped to a PaneProbe: its pane's ROOT pid
|
|
315
|
+
// (`#{pane_pid}`) plus ONE shared whole-box pid→(ppid, comm) table from a single `ps` spawn. tmux server
|
|
316
|
+
// down / no sessions → empty map → everything reconciles to offline, which is correct. `live.has(id)` = the
|
|
317
|
+
// window presence; `live.get(id)` = the probe, which the CODEX adapter's liveness walks to tell a running TUI
|
|
318
|
+
// (a codex/node process among the pane pid's descendants) from a failed launch that dropped back to the bare
|
|
319
|
+
// shell (see [[harness-adapter]] paneTreeRunsCodex — the pane's FOREGROUND command is `bash`, the launch
|
|
320
|
+
// wrapper, even while the TUI renders, so the foreground name is NOT the signal). CLAUDE ignores the probe —
|
|
321
|
+
// its workers launch through the `reclaude` wrapper, which runs claude as a CHILD, so claude liveness stays
|
|
322
|
+
// its rendezvous socket. The per-session alive() above stays for the single-session ops (capture / rawKey).
|
|
323
|
+
async function procSnapshot(): Promise<ProcTable> {
|
|
324
|
+
const t: ProcTable = new Map()
|
|
284
325
|
let out = ''
|
|
285
|
-
try { out = await
|
|
286
|
-
for (const line of out.split('\n')) {
|
|
287
|
-
|
|
326
|
+
try { ({ stdout: out } = await pexec('ps', ['-eo', 'pid=,ppid=,comm='], { timeout: TMUX_PROBE_TIMEOUT_MS, killSignal: 'SIGKILL' })) } catch { return t }
|
|
327
|
+
for (const line of out.split('\n')) {
|
|
328
|
+
const m = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line)
|
|
329
|
+
if (m) t.set(Number(m[1]), { ppid: Number(m[2]), comm: m[3].trim() })
|
|
330
|
+
}
|
|
331
|
+
return t
|
|
332
|
+
}
|
|
333
|
+
// @@@ LiveSnap - the ONE liveness snapshot the whole session list shares. `windows` = our live tmux windows
|
|
334
|
+
// (id → PaneProbe) + one whole-box process table; `sockets` = the ids whose rendezvous socket has a LIVE
|
|
335
|
+
// LISTENER (connect-probed once here, not the file-exists lie — [[harness-adapter]]); `probeFailed` = the tmux
|
|
336
|
+
// window probe itself FAILED (timed out under load), which is DISTINCT from "tmux up, no sessions" — the former
|
|
337
|
+
// means death is UNPROVEN so those rows read `unknown`, the latter is authoritative and reads `offline`.
|
|
338
|
+
export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; sockets: Set<string> }
|
|
339
|
+
async function liveSnapshot(): Promise<LiveSnap> {
|
|
340
|
+
const windows = new Map<string, PaneProbe>()
|
|
341
|
+
let out: string
|
|
342
|
+
try {
|
|
343
|
+
out = await tmux(['list-sessions', '-F', '#{session_name}\t#{pane_pid}'], TMUX_PROBE_TIMEOUT_MS)
|
|
344
|
+
} catch (e) {
|
|
345
|
+
// a TIMEOUT/kill is a probe FAILURE (we can't tell who's alive → unknown, never a false graveyard). A clean
|
|
346
|
+
// non-zero exit ("no server running" — genuinely zero sessions) is authoritative → the empty map = offline.
|
|
347
|
+
return { probeFailed: probeTimedOut(e), windows, sockets: new Set() }
|
|
348
|
+
}
|
|
349
|
+
const procs = await procSnapshot().catch(() => undefined) // codex-only, auxiliary; its failure isn't a liveness failure
|
|
350
|
+
for (const line of out.split('\n')) {
|
|
351
|
+
const tab = line.indexOf('\t'); if (tab < 0) { const name = line.trim(); if (name) windows.set(name, { procs }); continue }
|
|
352
|
+
const name = line.slice(0, tab).trim(); if (!name) continue
|
|
353
|
+
const pid = Number(line.slice(tab + 1).trim())
|
|
354
|
+
windows.set(name, { panePid: Number.isFinite(pid) && pid > 0 ? pid : undefined, procs })
|
|
355
|
+
}
|
|
356
|
+
// LISTENER probe for every windowed session, once, in parallel (tooth: a live listener, not a lingering
|
|
357
|
+
// socket file). A codex session has no rvSock → instant ENOENT → not in the set, and codex ignores it anyway.
|
|
358
|
+
const ids = [...windows.keys()]
|
|
359
|
+
const listening = await Promise.all(ids.map((id) => rendezvousListening(id)))
|
|
360
|
+
const sockets = new Set<string>()
|
|
361
|
+
ids.forEach((id, i) => { if (listening[i]) sockets.add(id) })
|
|
362
|
+
return { probeFailed: false, windows, sockets }
|
|
288
363
|
}
|
|
289
364
|
|
|
290
365
|
// @@@ paneTitles - every session pane's RAW tmux title, free from tmux. The worker launches one pane per
|
|
@@ -305,6 +380,19 @@ async function paneTitles(): Promise<Map<string, string>> {
|
|
|
305
380
|
return m
|
|
306
381
|
}
|
|
307
382
|
|
|
383
|
+
// @@@ sessionSignature - a CHEAP fingerprint of the two live board signals the session-store fs-watch can't
|
|
384
|
+
// see, because they are tmux-derived, not file writes: LIVENESS (which sessions exist — a crash/offline) and
|
|
385
|
+
// ACTIVITY (each pane's self-summary title). Two tmux calls, NO git and NO store walk, so [[board-stream]] can
|
|
386
|
+
// poll this to push a `board-changed` the instant a worker dies or updates its headline, instead of the
|
|
387
|
+
// dashboard waiting for its slow cold-path fallback. Sorted so it only moves on a real change.
|
|
388
|
+
export async function sessionSignature(): Promise<string> {
|
|
389
|
+
const [snap, titles] = await Promise.all([liveSnapshot(), paneTitles()])
|
|
390
|
+
// fold in probe-failure and the live-listener set so a socket dying (claude exit) OR the probe flipping to
|
|
391
|
+
// unknown pushes a board-changed immediately, not only on window churn.
|
|
392
|
+
return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
|
|
393
|
+
[...snap.sockets].sort().join(',') + '|' + [...titles].sort().map(([k, v]) => `${k}=${v}`).join(',')
|
|
394
|
+
}
|
|
395
|
+
|
|
308
396
|
// @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
|
|
309
397
|
// NOT become) a session's headline activity. The board headline derives from the pane title ONLY for a
|
|
310
398
|
// harness whose pane title is its own task self-summary (`paneTitleIsSelfSummary`, an adapter capability —
|
|
@@ -328,9 +416,17 @@ export function paneActivity(harness: Harness, paneTitle: string | null | undefi
|
|
|
328
416
|
// splash. The leading glyph run (with the spaces/`·` between and after) is stripped — the dashboard draws its
|
|
329
417
|
// own status dot, a frozen spinner frame is just noise — leaving only the summary text (null if it is empty).
|
|
330
418
|
// ONE regex is the single source of the glyph rule: it gates (requires ≥1 glyph) and strips in one match.
|
|
419
|
+
// The glyph gate alone is not enough: Claude Code emits a glyph-led SPLASH of its own app name (`✳ Claude
|
|
420
|
+
// Code`) between pane birth and its first real task summary — it CLEARS the glyph gate yet is the app naming
|
|
421
|
+
// itself, not the task. GENERIC_SUMMARY rejects that stripped splash too, so the row keeps its launch-prompt
|
|
422
|
+
// placeholder instead of flashing "Claude Code" for a tick (the glyph-LESS `Claude Code` splash was already
|
|
423
|
+
// rejected by the gate; this catches its glyph-led twin).
|
|
424
|
+
const GENERIC_SUMMARY = /^claude code$/i
|
|
331
425
|
export function selfSummary(paneTitle: string): string | null {
|
|
332
426
|
const m = /^[\s·]*(?:[✳✶✻✽✢⠀-⣿][\s·]*)+(.*)$/u.exec(paneTitle)
|
|
333
|
-
|
|
427
|
+
if (!m) return null
|
|
428
|
+
const text = m[1].trim()
|
|
429
|
+
return text && !GENERIC_SUMMARY.test(text) ? text : null
|
|
334
430
|
}
|
|
335
431
|
|
|
336
432
|
// @@@ launchedAt - when we last started a tmux window for an id (set in launch()). claude needs ~15-20s
|
|
@@ -339,23 +435,32 @@ export function selfSummary(paneTitle: string): string | null {
|
|
|
339
435
|
// instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
|
|
340
436
|
// memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
|
|
341
437
|
const launchedAt = new Map<string, number>()
|
|
342
|
-
const BOOT_GRACE_MS =
|
|
438
|
+
const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans launchScript's bounded fast-fail retry
|
|
439
|
+
// window (~3 attempts) so a relaunching session reads 'starting', not 'offline'
|
|
440
|
+
const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this — the
|
|
441
|
+
// launcher daemon-not-ready race fails in ~8s; a real session runs far longer
|
|
343
442
|
|
|
344
443
|
// @@@ liveness - the orthogonal axis ([[state]]): is the agent process up, for ANY session regardless of
|
|
345
|
-
// lifecycle, from a prebuilt
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
|
|
444
|
+
// lifecycle, from a prebuilt runtime snapshot (no per-call spawn — see liveSnapshot) + the adapter's own channel
|
|
445
|
+
// check. Order of honesty: if the PROBE ITSELF failed (tmux timed out under load) death is UNPROVEN → `unknown`
|
|
446
|
+
// (render probe-failed, NEVER a false offline that empties the board and provokes a mass-restore). Else offline
|
|
447
|
+
// iff the tmux window is gone OR the adapter's online-signal is absent past the boot window. claude (via the
|
|
448
|
+
// reclaude wrapper) holds CLAUDE_BG_RENDEZVOUS_SOCK open the whole time it is alive, so a LIVE LISTENER on that
|
|
449
|
+
// socket (`snap.sockets`, connect-probed — NOT the socket FILE, which a crash leaves behind) is the truth —
|
|
450
|
+
// not the pane, whose foreground is the wrapper/shell while claude runs as its child. codex has no such socket,
|
|
451
|
+
// so its truth is the pane's DESCENDANT PROCESS TREE from the SAME snapshot: a live TUI keeps a codex/node
|
|
452
|
+
// process below the pane pid; a failed launch leaves the pane at a bare shell, even while the shared app-server
|
|
453
|
+
// sock lingers. A just-launched agent whose online-signal hasn't appeared yet reads the transient 'starting'
|
|
454
|
+
// for the grace window; only past it (still not online) is it genuinely 'offline'.
|
|
455
|
+
export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
352
456
|
if (!rec.session) return 'offline'
|
|
353
|
-
|
|
354
|
-
//
|
|
355
|
-
//
|
|
356
|
-
//
|
|
457
|
+
if (snap.probeFailed) return 'unknown' // the probe failed — we can't tell, and MUST NOT guess offline
|
|
458
|
+
// ask the ADAPTER ([[harness-adapter]]): claude = tmux up AND a live listener on its rendezvous socket; codex
|
|
459
|
+
// = tmux up AND a codex-ish process live among the pane pid's descendants (not the bare shell a failed launch
|
|
460
|
+
// dropped back to). The 'starting' grace stays here (a launcher concern): a just-launched agent whose
|
|
461
|
+
// online-signal hasn't appeared yet reads 'starting' for the boot window, only past it 'offline'.
|
|
357
462
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
358
|
-
if (h.liveness(rec,
|
|
463
|
+
if (h.liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online') return 'online'
|
|
359
464
|
const at = launchedAt.get(rec.session)
|
|
360
465
|
return at && Date.now() - at < BOOT_GRACE_MS ? 'starting' : 'offline'
|
|
361
466
|
}
|
|
@@ -363,14 +468,14 @@ function liveness(rec: SessRec, live: Set<string>): Liveness {
|
|
|
363
468
|
// reconcile the compact DisplayStatus — a DERIVED label composing lifecycle + liveness for one-glyph
|
|
364
469
|
// surfaces ([[state]]), never a third source of truth. Lifecycle wins the label except where liveness must
|
|
365
470
|
// show through: awaiting → its proposal label; parked/error/asking/queued → themselves; active/idle → their
|
|
366
|
-
// liveness (offline/starting), else the active-only idle/working inference (the mark-active hook flips
|
|
367
|
-
// → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
|
|
471
|
+
// liveness (offline/starting/unknown), else the active-only idle/working inference (the mark-active hook flips
|
|
472
|
+
// idle → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
|
|
368
473
|
// terminal-mount and the relaunch panel on; this label is for badges and `spex ls`.
|
|
369
|
-
function reconcile(rec: SessRec,
|
|
474
|
+
function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
|
|
370
475
|
if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
|
|
371
476
|
if (rec.status !== 'active' && rec.status !== 'idle') return rec.status // parked | error | asking | queued (no tmux yet)
|
|
372
|
-
const lv = liveness(rec,
|
|
373
|
-
if (lv !== 'online') return lv // 'offline' | 'starting'
|
|
477
|
+
const lv = liveness(rec, snap)
|
|
478
|
+
if (lv !== 'online') return lv // 'offline' | 'starting' | 'unknown'
|
|
374
479
|
return rec.status === 'idle' ? 'idle' : 'working'
|
|
375
480
|
}
|
|
376
481
|
|
|
@@ -383,12 +488,15 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
|
|
|
383
488
|
return { path: rec.worktreePath, branch: rec.branch, rec }
|
|
384
489
|
}
|
|
385
490
|
|
|
386
|
-
function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
|
|
491
|
+
export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
|
|
387
492
|
const prompt = readPromptFile(rec.session) // the originating ask, captured at launch (store artifact; null for old sessions)
|
|
388
493
|
// activity is the LIVE pane title; it only means anything while the worker is genuinely up — a
|
|
389
494
|
// dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
|
|
390
495
|
const showActivity = lv === 'online'
|
|
391
|
-
|
|
496
|
+
const act = showActivity ? activity : null
|
|
497
|
+
const pp = prompt ? promptPreview(prompt) : null
|
|
498
|
+
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
|
|
499
|
+
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: rec.harness, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
|
|
392
500
|
}
|
|
393
501
|
|
|
394
502
|
// @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
|
|
@@ -434,8 +542,8 @@ const lastKnownSession = new Map<string, Session>()
|
|
|
434
542
|
export async function listSessions(): Promise<Session[]> {
|
|
435
543
|
// ONE store enumeration + ONE tmux liveness snapshot + ONE pane-title snapshot for the whole list (all
|
|
436
544
|
// independent), then every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
|
|
437
|
-
const [ids,
|
|
438
|
-
Promise.resolve(listSessionIds()),
|
|
545
|
+
const [ids, snap, titles] = await Promise.all([
|
|
546
|
+
Promise.resolve(listSessionIds()), liveSnapshot(), paneTitles(),
|
|
439
547
|
])
|
|
440
548
|
const rows = ids.map((id) => guardSession(id, () => {
|
|
441
549
|
const rec = readRecord(id)
|
|
@@ -443,7 +551,7 @@ export async function listSessions(): Promise<Session[]> {
|
|
|
443
551
|
// the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
|
|
444
552
|
// is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
|
|
445
553
|
const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), titles.get(id))
|
|
446
|
-
const s = toSession(rec, reconcile(rec,
|
|
554
|
+
const s = toSession(rec, reconcile(rec, snap), liveness(rec, snap), activity)
|
|
447
555
|
lastKnownSession.set(id, s)
|
|
448
556
|
return s
|
|
449
557
|
}, () => {
|
|
@@ -613,17 +721,26 @@ export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/se
|
|
|
613
721
|
// backend-down poll must NOT be swallowed as a transient git/tmux hiccup: watch warns ONCE and keeps
|
|
614
722
|
// streaming rather than emitting false `closed` events for every session.
|
|
615
723
|
export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.name === 'BackendError'
|
|
724
|
+
// @@@ isBackendUnreachable - the TRANSIENT subset of isBackendDown: the fetch itself failed (nothing
|
|
725
|
+
// listening — ECONNREFUSED / "fetch failed"), which client.ts throws as a BackendError with NO HTTP
|
|
726
|
+
// `status`. An HTTP BackendError (the backend answered non-2xx) DOES carry a status and is a real error, not
|
|
727
|
+
// a momentary blip. The distinction matters to `spex wait`: a supervisor's backgrounded wait must survive
|
|
728
|
+
// the ~1s window where the supervisor reboots its hot-reloaded child behind the stable port, retrying until
|
|
729
|
+
// the backend answers again or the deadline hits — never dying on the in-flight fetch that a sibling merge's
|
|
730
|
+
// restart happens to interrupt. Read via a structural cast (no client.ts import — that would be a cycle).
|
|
731
|
+
export const isBackendUnreachable = (e: unknown): boolean =>
|
|
732
|
+
isBackendDown(e) && (e as { status?: number }).status === undefined
|
|
616
733
|
|
|
617
734
|
const slugify = (s: string | null) => (s || 'session').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'session'
|
|
618
735
|
|
|
619
736
|
// @@@ node + title from the prompt - the spec node a session works on is whatever it @-mentions, NOT a UI
|
|
620
737
|
// "focused node": the dashboard prefills `@<focused> ` as a deletable convenience, so the node the user
|
|
621
738
|
// actually left in the prompt (changed it, or deleted it for a node-agnostic prompt) is the truth. We read
|
|
622
|
-
// the FIRST
|
|
623
|
-
// is none, the session is node-agnostic and we label it by the first few words of the prompt
|
|
739
|
+
// the FIRST `[[<id>]]` topic reference ([[mentions]]: `[[node]]` is a topic, `@` is now an actor/session).
|
|
740
|
+
// When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
|
|
624
741
|
// The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
|
|
625
|
-
// (`.config`) keeps the dot — without `\.?` here
|
|
626
|
-
const MENTION =
|
|
742
|
+
// (`.config`) keeps the dot — without `\.?` here `[[.config]]` captures nothing and never resolves to a node.
|
|
743
|
+
const MENTION = /\[\[(\.?[A-Za-z0-9_-]+)\]\]/
|
|
627
744
|
const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
|
|
628
745
|
function titleFromPrompt(prompt: string): string | null {
|
|
629
746
|
const first = (prompt || '').trim().split('\n')[0].trim()
|
|
@@ -642,117 +759,52 @@ function titleFromPrompt(prompt: string): string | null {
|
|
|
642
759
|
// `reclaude` wrapper, a grandchild), so the pane command is the wrapper/shell — reconcile reads claude's
|
|
643
760
|
// rendezvous socket instead (present while claude is alive, gone once it exits). The file lives OUTSIDE the
|
|
644
761
|
// worktree (in the store, keyed by session_id), so it never pollutes the spec/code work.
|
|
645
|
-
|
|
762
|
+
// the launch command for THIS session ([[launcher-select]] resume-launcher-pin): the RESOLVED base command
|
|
763
|
+
// PINNED on the record at creation wins — so a (re)launch replays the EXACT launcher that made the conversation
|
|
764
|
+
// (and its config-dir env), never re-resolving against a since-changed default that would send `--resume` to the
|
|
765
|
+
// wrong config dir and lose the transcript. Fall back to the named-launcher resolution (an old record with a
|
|
766
|
+
// launcher name but no pinned cmd; fail-loud on a since-removed launcher), then undefined (truly old record →
|
|
767
|
+
// the harness adapter's ambient resolution, best-effort).
|
|
768
|
+
export function launcherCmd(rec: SessRec): string | undefined {
|
|
769
|
+
if (rec.launchCmd) return rec.launchCmd
|
|
770
|
+
return rec.launcher ? resolveLauncher(rec.launcher).cmd : undefined
|
|
771
|
+
}
|
|
772
|
+
function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
|
|
646
773
|
const file = join(storeDir(id), 'launch.sh')
|
|
647
774
|
// NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
|
|
648
775
|
// createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
|
|
649
776
|
// agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
|
|
650
|
-
|
|
777
|
+
// `cmd` is the session's persisted launcher command ([[launcher-select]]); when set it OVERRIDES the harness's
|
|
778
|
+
// ambient default so resume reuses the same auth, else undefined → the unnamed global resolution.
|
|
779
|
+
const invocation = `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot(), cmd)} ${tail}`
|
|
780
|
+
// Bounded relaunch on a FAST exit: the agent launcher (e.g. the reclaude daemon) can lose a startup
|
|
781
|
+
// race ("daemon did not become ready") and exit within seconds before the rendezvous socket ever
|
|
782
|
+
// appears — a fast exit is the race, not real work, so retry a few times. Once the agent has run past
|
|
783
|
+
// LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal session end
|
|
784
|
+
// and is NEVER retried — the loop exits. BOOT_GRACE_MS and SOCKET_READY_TIMEOUT_MS both span this retry
|
|
785
|
+
// window, so liveness stays 'starting' and waitForReady keeps holding the slot across retries. This only
|
|
786
|
+
// closes the startup race — it adds no fallback and never masks a genuinely dead agent (3 attempts, then give up).
|
|
787
|
+
writeFileSync(file, [
|
|
788
|
+
`for __spex_try in 1 2 3; do`,
|
|
789
|
+
` __spex_t0=$SECONDS`,
|
|
790
|
+
` ${invocation}`,
|
|
791
|
+
` __spex_rc=$?`,
|
|
792
|
+
` [ $(( SECONDS - __spex_t0 )) -ge ${LAUNCH_FAST_FAIL_S} ] && exit $__spex_rc`,
|
|
793
|
+
` printf '[spex launch] attempt %s exited in %ss (rc=%s) — likely a launcher daemon race; retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
794
|
+
` sleep 2`,
|
|
795
|
+
`done`,
|
|
796
|
+
`exit $__spex_rc`,
|
|
797
|
+
``,
|
|
798
|
+
].join('\n'))
|
|
651
799
|
return file
|
|
652
800
|
}
|
|
653
|
-
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS): Promise<void> {
|
|
801
|
+
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
|
|
654
802
|
await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path])
|
|
655
|
-
await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness)}`])
|
|
803
|
+
await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`])
|
|
656
804
|
await tmux(['send-keys', '-t', id, 'Enter'])
|
|
657
805
|
launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
|
|
658
806
|
}
|
|
659
807
|
|
|
660
|
-
// @@@ node directives - a dashboard board chord (nn / dd) prefixes the New Session prompt with a
|
|
661
|
-
// structured op the server PERFORMS in the fresh worktree before the agent starts, then hands the agent
|
|
662
|
-
// a prompt to finish it intelligently. The directive is anchored at the prompt start and carries an
|
|
663
|
-
// @<target>, so it's unambiguous and wins over the plain first-@ mention. `rest` is the human's own text
|
|
664
|
-
// after it (what they want the new node to be, or why the node is going away). No directive → the prompt
|
|
665
|
-
// is an ordinary session prompt and nothing is mutated.
|
|
666
|
-
// @new under @<parentId>: <describe the node> → create a placeholder child, agent names+specs+codes it
|
|
667
|
-
// @delete @<nodeId>: <why / guidance> → remove the node's dir, agent refactors per git history
|
|
668
|
-
type Directive = { kind: 'new'; targetId: string; rest: string } | { kind: 'delete'; targetId: string; rest: string }
|
|
669
|
-
const NEW_OP = /^\s*@new\b[^\n@]*@([A-Za-z0-9_-]+)\s*:?\s*/i
|
|
670
|
-
const DEL_OP = /^\s*@delete\b[^\n@]*@([A-Za-z0-9_-]+)\s*:?\s*/i
|
|
671
|
-
function parseDirective(prompt: string): Directive | null {
|
|
672
|
-
let m = prompt.match(NEW_OP); if (m) return { kind: 'new', targetId: m[1], rest: prompt.slice(m[0].length).trim() }
|
|
673
|
-
m = prompt.match(DEL_OP); if (m) return { kind: 'delete', targetId: m[1], rest: prompt.slice(m[0].length).trim() }
|
|
674
|
-
return null
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
// find a spec node's directory inside a worktree's .spec tree (id = dir basename, the node-identity rule).
|
|
678
|
-
function findNodeDir(specRoot: string, nodeId: string): string | null {
|
|
679
|
-
if (!existsSync(specRoot)) return null
|
|
680
|
-
const stack = [specRoot]
|
|
681
|
-
while (stack.length) {
|
|
682
|
-
const dir = stack.pop()!
|
|
683
|
-
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
684
|
-
if (!e.isDirectory()) continue
|
|
685
|
-
const child = join(dir, e.name)
|
|
686
|
-
if (e.name === nodeId && existsSync(join(child, 'spec.md'))) return child
|
|
687
|
-
stack.push(child)
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
return null
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
// a lint-clean placeholder spec.md: minimal valid frontmatter + a two-part body, NO `code:` list (an
|
|
694
|
-
// empty governed-files list keeps `spex lint` integrity at 0 errors). The agent replaces it wholesale.
|
|
695
|
-
function placeholderSpec(id: string, sessionId: string): string {
|
|
696
|
-
return [
|
|
697
|
-
'---', `title: ${id}`, 'status: pending', 'hue: 210',
|
|
698
|
-
'desc: placeholder — to be named and specified by the dispatched session.',
|
|
699
|
-
`session: ${sessionId}`, '---', `# ${id}`, '',
|
|
700
|
-
'## raw source', '',
|
|
701
|
-
'Placeholder node. The dispatched session replaces this with the real human intent, renames the',
|
|
702
|
-
'directory to a proper id, and writes the matching spec and code.', '',
|
|
703
|
-
'## expanded spec', '',
|
|
704
|
-
'Pending — authored by the dispatched session.', '',
|
|
705
|
-
].join('\n')
|
|
706
|
-
}
|
|
707
|
-
// create the placeholder child under <parentId> (or the .spec root if the parent isn't in this worktree).
|
|
708
|
-
// returns the new spec.md path relative to the worktree, for the agent prompt.
|
|
709
|
-
function createPlaceholder(wtPath: string, parentId: string, placeholderId: string, sessionId: string): string {
|
|
710
|
-
const specRoot = join(wtPath, '.spec')
|
|
711
|
-
const parentDir = findNodeDir(specRoot, parentId) || specRoot
|
|
712
|
-
const dir = join(parentDir, placeholderId)
|
|
713
|
-
mkdirSync(dir, { recursive: true })
|
|
714
|
-
writeFileSync(join(dir, 'spec.md'), placeholderSpec(placeholderId, sessionId))
|
|
715
|
-
return relative(wtPath, join(dir, 'spec.md'))
|
|
716
|
-
}
|
|
717
|
-
// remove a node's whole directory (its subtree). Returns the deleted spec.md's worktree-relative path
|
|
718
|
-
// (so the agent can `git log --follow` it), or null when the node isn't present in this worktree.
|
|
719
|
-
function removeNode(wtPath: string, nodeId: string): string | null {
|
|
720
|
-
const dir = findNodeDir(join(wtPath, '.spec'), nodeId)
|
|
721
|
-
if (!dir) return null
|
|
722
|
-
const rel = relative(wtPath, join(dir, 'spec.md'))
|
|
723
|
-
rmSync(dir, { recursive: true, force: true })
|
|
724
|
-
return rel
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
// @@@ directive prompts - the INTENT handed to the dispatched agent. The server did the mechanical
|
|
728
|
-
// spec-tree mutation; the agent does the intelligent rest (name + spec + code, or history-driven
|
|
729
|
-
// refactor). Like mergePrompt, the op is a DISPATCH: the server never authors specs or refactors code.
|
|
730
|
-
// These state only the TASK — they deliberately do NOT restate the git flow's mechanics (commit format, the
|
|
731
|
-
// Session: trailer, the node-branch flow, the merge style). Those are carried by product MECHANISM, not a
|
|
732
|
-
// dispatch string: newSession makes the branch, the prepare-commit-msg hook stamps the trailer, the
|
|
733
|
-
// `core/spec` system contract (gathered into appendSysArg) demands commit-before-declare, and mergePrompt
|
|
734
|
-
// states the merge style at merge time. The only handoff detail kept here is "propose merge, don't merge
|
|
735
|
-
// yourself" (the human triggers
|
|
736
|
-
// the merge later, see mergePrompt).
|
|
737
|
-
function newNodePrompt(placeholderId: string, parentId: string, relPath: string, rest: string): string {
|
|
738
|
-
return `A placeholder spec node \`${placeholderId}\` was created under parent \`${parentId}\` at ${relPath} in this worktree. ` +
|
|
739
|
-
`Turn it into a real node and build it, per this request:\n\n${rest || '(no extra description — infer the intent from the parent and the codebase)'}\n\n` +
|
|
740
|
-
`1. Choose a good kebab-case id reflecting the intent (node id = its directory basename) and \`git mv\` the directory \`${dirname(relPath)}\` to it, keeping it under \`${parentId}\`. ` +
|
|
741
|
-
`2. Rewrite spec.md at contract altitude: real title/desc, the two-part body (raw source = human intent · expanded spec = behavioral contract), and a \`code:\` list of the files it will govern. ` +
|
|
742
|
-
`3. Implement the code the spec describes. 4. Keep \`spex lint\` at 0 errors and the build green. ` +
|
|
743
|
-
`When it's ready, propose merge for the human to review — do NOT merge it yourself.`
|
|
744
|
-
}
|
|
745
|
-
function deleteNodePrompt(nodeId: string, relPath: string | null, rest: string): string {
|
|
746
|
-
const recover = relPath
|
|
747
|
-
? `Recover what it was: \`git log --follow -- ${relPath}\` then \`git show\` the relevant commits to read its old spec and the \`code:\` files it governed.`
|
|
748
|
-
: `The node's spec.md wasn't found in the tree; recover what \`${nodeId}\` was from git history (\`git log\` / \`git show\`).`
|
|
749
|
-
return `The spec node \`${nodeId}\` has been intentionally DELETED (its directory removed) in this worktree. ` +
|
|
750
|
-
`Make the codebase consistent without it, per this request:\n\n${rest || '(no extra guidance — use your judgement)'}\n\n` +
|
|
751
|
-
`1. ${recover} ` +
|
|
752
|
-
`2. Decide what happens to that governed code now the spec is gone — remove it, fold it into another node's responsibility, or re-point references — and fix any specs that linked \`[[${nodeId}]]\`. ` +
|
|
753
|
-
`3. Apply the refactor; keep \`spex lint\` at 0 errors and the build green. ` +
|
|
754
|
-
`When it's ready, propose merge for the human to review — do NOT merge it yourself.`
|
|
755
|
-
}
|
|
756
808
|
|
|
757
809
|
// @@@ concurrency cap + queue - keep at most maxActive() agents AUTONOMOUSLY PROGRESSING at once. A slot is
|
|
758
810
|
// COMPUTE pressure, so only an agent actually consuming it holds one: genuinely live (tmux window + rendezvous
|
|
@@ -764,11 +816,11 @@ function deleteNodePrompt(nodeId: string, relPath: string | null, rest: string):
|
|
|
764
816
|
// directly (the socket truth reconcile uses), so an authored `parked` whose claude has since died does NOT
|
|
765
817
|
// pin a slot. The cap throttles concurrent COMPUTE; everything waiting-on-you waits cheap as a live pane.
|
|
766
818
|
const OCCUPIES_SLOT = new Set<DisplayStatus>(['working', 'parked', 'starting']) // starting's boot window is also held via `launching`
|
|
767
|
-
function isOccupying(s: Session,
|
|
819
|
+
function isOccupying(s: Session, snap: LiveSnap): boolean {
|
|
768
820
|
if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
|
|
769
821
|
const rec = readRecord(s.id)
|
|
770
822
|
if (!rec) return false
|
|
771
|
-
return harnessById(rec.harness || defaultHarness.id).liveness(rec,
|
|
823
|
+
return harnessById(rec.harness || defaultHarness.id).liveness(rec, snap.windows.has(rec.session), runtimeRoot(), snap.windows.get(rec.session), snap.sockets.has(rec.session)) === 'online' // and only while the agent is genuinely live (its adapter's channel)
|
|
772
824
|
}
|
|
773
825
|
// sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
|
|
774
826
|
// `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
|
|
@@ -790,7 +842,7 @@ async function startQueued(id: string): Promise<boolean> {
|
|
|
790
842
|
const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
|
|
791
843
|
try {
|
|
792
844
|
const sq = `'${launchPrompt.replace(/'/g, `'\\''`)}'`
|
|
793
|
-
await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h)
|
|
845
|
+
await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec))
|
|
794
846
|
} catch {
|
|
795
847
|
launching.delete(id)
|
|
796
848
|
return false // launch failed → stays `queued`, retried on the next drain tick
|
|
@@ -812,10 +864,15 @@ export async function drainQueue(): Promise<void> {
|
|
|
812
864
|
if (draining) return
|
|
813
865
|
draining = true
|
|
814
866
|
try {
|
|
815
|
-
const cap = maxActive() // read once per drain pass (spexcode.json → env →
|
|
867
|
+
const cap = maxActive() // read once per drain pass (spexcode.json → env → default); won't shift mid-burst
|
|
816
868
|
for (;;) {
|
|
817
|
-
const [sessions,
|
|
818
|
-
|
|
869
|
+
const [sessions, snap] = await Promise.all([listSessions(), liveSnapshot()])
|
|
870
|
+
// if the liveness probe FAILED (tmux timing out — the overload condition), occupancy is UNKNOWABLE: every
|
|
871
|
+
// session would read window-less and isOccupying would undercount, so the drainer would OVER-launch and pile
|
|
872
|
+
// MORE compute onto an already-thrashing box. Under load, do the safe thing — launch nothing this pass and
|
|
873
|
+
// let the next tick re-drain once the probe recovers ([[state]] board honesty applied to the cap).
|
|
874
|
+
if (snap.probeFailed) break
|
|
875
|
+
const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, snap) ? 1 : 0), 0)
|
|
819
876
|
if (occupied >= cap) break
|
|
820
877
|
const next = sessions.find((s) => s.status === 'queued' && !launching.has(s.id))
|
|
821
878
|
if (!next) break
|
|
@@ -840,6 +897,32 @@ export function superviseQueue(intervalMs = 3000): void {
|
|
|
840
897
|
void tick()
|
|
841
898
|
}
|
|
842
899
|
|
|
900
|
+
// @@@ assertProjectMatch - the launch is PROJECT-BOUND, but routing is by URL. `spex new`'s intent is "create a
|
|
901
|
+
// session for the project my cwd is in", yet `apiBase()` is SPEXCODE_API_URL||PORT||8787 — a pure URL carrying
|
|
902
|
+
// no project identity. The backend it answers builds the worktree under ITS OWN mainRoot, so a stale inherited
|
|
903
|
+
// SPEXCODE_API_URL (e.g. pointing at another repo's backend) silently lands the session in the WRONG repo. Read/
|
|
904
|
+
// control verbs deliberately point anywhere (viewer-points-anywhere, see remote-client); only the mutating
|
|
905
|
+
// launch is bound to the caller's project. So before launching, compare the caller's repo root to the backend's
|
|
906
|
+
// served root and FAIL LOUD on a provable, same-host mismatch — never a silent misroute. The guard fires only on
|
|
907
|
+
// a positive mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local
|
|
908
|
+
// path (a genuinely remote backend) all fall through to allow, so legit remote dispatch is untouched.
|
|
909
|
+
async function assertProjectMatch(): Promise<void> {
|
|
910
|
+
let localMain: string
|
|
911
|
+
try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
|
|
912
|
+
let served: string | null = null
|
|
913
|
+
try {
|
|
914
|
+
const r = await fetch(`${apiBase()}/api/layout`)
|
|
915
|
+
if (r.ok) served = (await r.json() as { main?: string }).main ?? null
|
|
916
|
+
} catch { return } // backend unreachable → the POST surfaces it (fail-loud there)
|
|
917
|
+
if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
|
|
918
|
+
let backendMain: string
|
|
919
|
+
try { backendMain = realpathSync(served) } catch { return } // backend root not a local path → a remote backend, allow
|
|
920
|
+
if (backendMain !== localMain)
|
|
921
|
+
throw new Error(
|
|
922
|
+
`spex new: refusing — cwd is in ${localMain} but the backend at ${apiBase()} serves ${backendMain}.\n` +
|
|
923
|
+
`Start this project's backend: cd ${localMain} && spex serve (or point SPEXCODE_API_URL at it)`)
|
|
924
|
+
}
|
|
925
|
+
|
|
843
926
|
// @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
|
|
844
927
|
// BACKEND's process, not the caller's. The backend owns the launch env (notably SPEXCODE_CLAUDE_CMD, which
|
|
845
928
|
// reclaude strips from agent envs) AND the concurrency cap. An agent that runs `spex new` (e.g. a supervisor)
|
|
@@ -847,17 +930,23 @@ export function superviseQueue(intervalMs = 3000): void {
|
|
|
847
930
|
// the CLI POSTs to the running backend whenever one answers, making the backend the single owner of session
|
|
848
931
|
// launching. Only when NO backend is reachable do we fall back to launching in this process (with a stderr
|
|
849
932
|
// warning) — the backend's own POST handler calls newSession directly, so it never re-enters this path.
|
|
850
|
-
export async function createSession(node: string | null, prompt: string, harness: string = defaultHarness.id): Promise<Session> {
|
|
933
|
+
export async function createSession(node: string | null, prompt: string, harness: string = defaultHarness.id, launcher?: string): Promise<Session> {
|
|
934
|
+
await assertProjectMatch()
|
|
935
|
+
// @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
|
|
936
|
+
// via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
|
|
937
|
+
// backend, whose process env carries no acting session id. An agent that runs `spex new` stamps its own id;
|
|
938
|
+
// a human in a plain shell has none → null → the new session is top-level (no phantom nesting).
|
|
939
|
+
const parent = ownSessionId()
|
|
851
940
|
let res: Response
|
|
852
941
|
try {
|
|
853
942
|
res = await fetch(`${apiBase()}/api/sessions`, {
|
|
854
943
|
method: 'POST',
|
|
855
944
|
headers: { 'content-type': 'application/json' },
|
|
856
|
-
body: JSON.stringify({ node, prompt, harness }),
|
|
945
|
+
body: JSON.stringify({ node, prompt, harness, parent, launcher }),
|
|
857
946
|
})
|
|
858
947
|
} catch {
|
|
859
948
|
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
860
|
-
return newSession(node, prompt, harness)
|
|
949
|
+
return newSession(node, prompt, harness, parent, launcher)
|
|
861
950
|
}
|
|
862
951
|
if (!res.ok) throw new Error(`backend rejected session (${res.status}): ${await res.text().catch(() => '')}`)
|
|
863
952
|
return await res.json() as Session
|
|
@@ -866,19 +955,25 @@ export async function createSession(node: string | null, prompt: string, harness
|
|
|
866
955
|
// @@@ newSession - durable worktree (branch node/<slug> off main) + .session label. The agent does NOT
|
|
867
956
|
// launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
|
|
868
957
|
// immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
|
|
869
|
-
// `spex session new`.
|
|
870
|
-
//
|
|
871
|
-
|
|
958
|
+
// `spex session new`. Creating or deleting a spec node is NOT a server op — it is prompt-driven work the
|
|
959
|
+
// launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
|
|
960
|
+
// only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
|
|
961
|
+
// programmatic surface, every other surface is prompt only).
|
|
962
|
+
export async function newSession(node: string | null, prompt: string, harness: string = defaultHarness.id, parent: string | null = null, launcher?: string): Promise<Session> {
|
|
872
963
|
const id = randomUUID()
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
//
|
|
876
|
-
//
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
const
|
|
881
|
-
|
|
964
|
+
// a named launcher ([[launcher-select]]) fixes BOTH the launch command (persisted below, re-resolved at each
|
|
965
|
+
// launch so resume reuses the same auth) AND the harness — so picking one SUBSUMES the harness axis. Explicit
|
|
966
|
+
// --launcher wins, else the configured defaultLauncher; an unknown name throws fail-loud (never a silent
|
|
967
|
+
// wrong-auth launch). No launcher → the harness arg (the zero-config path) with a null launcher field → the
|
|
968
|
+
// unnamed global command resolution at launch.
|
|
969
|
+
const lname = launcher ?? readConfig(mainRoot()).sessions?.defaultLauncher ?? null
|
|
970
|
+
const chosen = lname ? resolveLauncher(lname) : null
|
|
971
|
+
const h = harnessById(chosen ? chosen.harness : harness) // throws on an unknown id — fail loud, never silently launch the wrong harness
|
|
972
|
+
// node identity + label: explicit --node wins, else the prompt's first `[[id]]` topic ref; a prompt with
|
|
973
|
+
// none is node-agnostic and labeled by its first few words.
|
|
974
|
+
const ref = node || mentionedNode(prompt)
|
|
975
|
+
const title = ref ? null : titleFromPrompt(prompt)
|
|
976
|
+
const slug = `${slugify(ref || title)}-${id.slice(0, 4)}`
|
|
882
977
|
const branch = `node/${slug}`
|
|
883
978
|
const path = join(mainRoot(), '.worktrees', slug)
|
|
884
979
|
await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
|
|
@@ -889,8 +984,15 @@ export async function newSession(node: string | null, prompt: string, harness: s
|
|
|
889
984
|
// the worktree, is the board's enumeration source now).
|
|
890
985
|
const rec: SessRec = {
|
|
891
986
|
session: id, governed: true, worktreePath: path, branch,
|
|
892
|
-
|
|
893
|
-
|
|
987
|
+
// parent = the SPAWNING session's id, captured ONCE here ([[session-nesting]]): a durable pointer, never
|
|
988
|
+
// mutated after. A self-parent (a resolver quirk) is dropped so a session can't nest under itself.
|
|
989
|
+
node: ref || null, title, name: null, parent: parent && parent !== id ? parent : null,
|
|
990
|
+
status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
991
|
+
harness: h.id, harnessSessionId: null, launcher: lname,
|
|
992
|
+
// PIN the resolved base launcher command NOW ([[launcher-select]] resume-launcher-pin) so every future
|
|
993
|
+
// (re)launch replays THIS exact launcher — the one whose config-dir env holds the conversation — instead of
|
|
994
|
+
// re-resolving against a default that may have flipped (a backend restarted under a different launcher).
|
|
995
|
+
launchCmd: h.baseCmd(chosen?.cmd),
|
|
894
996
|
}
|
|
895
997
|
writeRecord(rec)
|
|
896
998
|
writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
|
|
@@ -900,18 +1002,9 @@ export async function newSession(node: string | null, prompt: string, harness: s
|
|
|
900
1002
|
// --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
|
|
901
1003
|
// own memory load too. One delivery path for both launch modes ([[harness-delivery]]).
|
|
902
1004
|
try { materialize(path) } catch { /* best-effort; the dispatch.sh gate re-renders on the first event anyway */ }
|
|
903
|
-
// perform the directive's spec-tree mutation in the worktree, then PARK the finish-the-op prompt for launch.
|
|
904
|
-
// the mutation is uncommitted, so the board's overlay shows it instantly (added ghost / deleted mark) even
|
|
905
|
-
// while the session only sits queued.
|
|
906
1005
|
let launchPrompt = prompt
|
|
907
|
-
if (
|
|
908
|
-
|
|
909
|
-
const relPath = createPlaceholder(path, directive.targetId, placeholderId, id)
|
|
910
|
-
launchPrompt = newNodePrompt(placeholderId, directive.targetId, relPath, directive.rest)
|
|
911
|
-
} else if (directive?.kind === 'delete') {
|
|
912
|
-
launchPrompt = deleteNodePrompt(directive.targetId, removeNode(path, directive.targetId), directive.rest)
|
|
913
|
-
} else if (ref) {
|
|
914
|
-
// @@@ spec pointer - the ref (explicit --node, else the prompt's first @mention) named an EXISTING node.
|
|
1006
|
+
if (ref) {
|
|
1007
|
+
// @@@ spec pointer - the ref (explicit --node, else the prompt's first [[id]] ref) named an EXISTING node.
|
|
915
1008
|
// Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
|
|
916
1009
|
// it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
|
|
917
1010
|
// is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
|
|
@@ -936,50 +1029,64 @@ export async function newSession(node: string | null, prompt: string, harness: s
|
|
|
936
1029
|
// BOUNDED + fail-loud preserved: a
|
|
937
1030
|
// genuinely dead/unrecoverable agent never goes online, so after the timeout we return and the caller's own
|
|
938
1031
|
// deliver() fails loud exactly as before — this only closes the startup race, it adds no fallback.
|
|
939
|
-
const SOCKET_READY_TIMEOUT_MS =
|
|
1032
|
+
const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fail relaunch window, so
|
|
1033
|
+
// waitForReady (slot-hold + reopen) waits through a daemon-race retry
|
|
1034
|
+
// instead of returning before a recovering socket
|
|
940
1035
|
const SOCKET_POLL_MS = 200
|
|
941
1036
|
async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<boolean> {
|
|
942
1037
|
const deadline = Date.now() + timeoutMs
|
|
943
1038
|
for (;;) {
|
|
944
1039
|
const rec = readRecord(id)
|
|
945
|
-
|
|
1040
|
+
const snap = await liveSnapshot() // window + pane probe + live-listener set in one snapshot — all the adapter needs
|
|
1041
|
+
if (rec && harness.liveness(rec, snap.windows.has(id), runtimeRoot(), snap.windows.get(id), snap.sockets.has(id)) === 'online') return true
|
|
946
1042
|
if (Date.now() >= deadline) return false
|
|
947
1043
|
await new Promise((r) => setTimeout(r, SOCKET_POLL_MS))
|
|
948
1044
|
}
|
|
949
1045
|
}
|
|
950
1046
|
|
|
951
|
-
// @@@ reopen -
|
|
952
|
-
// (
|
|
953
|
-
//
|
|
954
|
-
//
|
|
955
|
-
//
|
|
956
|
-
//
|
|
957
|
-
//
|
|
958
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
961
|
-
//
|
|
962
|
-
|
|
1047
|
+
// @@@ reopen - bring the agent back up and settle its RESTING lifecycle. THREE rules:
|
|
1048
|
+
// • RESUME GUARD ([[state]]): a relaunch KILLS the running agent (`kill-session` + fresh window), so it is a
|
|
1049
|
+
// data-loss operation the moment the agent is actually ALIVE — the incident's kill-shot was restore-on-alive
|
|
1050
|
+
// (the board LIED offline, the human relaunched, live workers died mid-work). So reopen re-derives the
|
|
1051
|
+
// agent's liveness FRESH and, when the caller is guarding (the human relaunch panel / `spex session reopen`,
|
|
1052
|
+
// `guard` default true), REFUSES LOUD rather than relaunch a live agent — you steer a live agent by
|
|
1053
|
+
// MESSAGING it, not by restoring it. Death must be PROVEN: an `unknown` probe (tmux timed out under load —
|
|
1054
|
+
// the exact condition that started the incident) also refuses, since a live worker can't be ruled out. A
|
|
1055
|
+
// `force` escape exists for a genuinely-wedged-but-alive process. The merge dispatch passes `guard:false`:
|
|
1056
|
+
// it only needs a LIVE agent to send the merge prompt to, so an already-online agent is a satisfied no-op
|
|
1057
|
+
// (never a refusal), and only a CONFIRMED-offline one is relaunched.
|
|
1058
|
+
// • liveness/relaunch: relaunch only when the agent is CONFIRMED `offline` (or `force`) — never on `online`
|
|
1059
|
+
// (alive), `starting` (booting), or `unknown` (unproven). We drop any stale pane and launch a fresh window
|
|
1060
|
+
// through the adapter's resumeArg — claude `--resume <id>` (the SAME conversation), codex `resume
|
|
1061
|
+
// <thread-id>` once captured, else a fresh TUI — then WAIT (waitForReady) so a caller that dispatches
|
|
1062
|
+
// immediately after (mergeSession's merge) addresses a LIVE agent, not a racing boot.
|
|
1063
|
+
// • lifecycle: the SAME active-only guard markIdle uses — a resumed agent that was WORKING (`active`) is now
|
|
1064
|
+
// just sitting at its prompt → `idle`; EVERY deliberate declaration survives untouched (`awaiting` + its
|
|
1065
|
+
// proposal, `asking`, `parked`, `error`, `queued`). reopen does NOT touch the `proposal` — resuming a
|
|
1066
|
+
// session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
|
|
1067
|
+
// a refusal leaves the record wholly untouched.
|
|
1068
|
+
// Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
|
|
1069
|
+
export async function reopen(id: string, opts: { force?: boolean; guard?: boolean } = {}): Promise<{ ok: boolean; error?: string; refused?: boolean }> {
|
|
1070
|
+
const { force = false, guard = true } = opts
|
|
963
1071
|
const wt = await findWorktree(id)
|
|
964
|
-
if (!wt) return false
|
|
1072
|
+
if (!wt) return { ok: false, error: `no such session ${id}` }
|
|
965
1073
|
const h = harnessById(wt.rec.harness || defaultHarness.id)
|
|
966
|
-
|
|
967
|
-
if (
|
|
968
|
-
|
|
969
|
-
|
|
1074
|
+
const lv = liveness(wt.rec, await liveSnapshot()) // FRESH, honest liveness (listener-verified) — the guard must not trust a stale board reading
|
|
1075
|
+
if (guard && !force && lv === 'online')
|
|
1076
|
+
return { ok: false, refused: true, error: `session ${id} is ALIVE — refusing to relaunch, which would kill a live worker mid-work. To steer it, send it a message; use force only for a genuinely wedged (but alive) process.` }
|
|
1077
|
+
if (guard && !force && lv === 'unknown')
|
|
1078
|
+
return { ok: false, refused: true, error: `session ${id}: the liveness probe failed (the box is likely overloaded) — refusing to relaunch since a live worker can't be ruled out. Retry in a moment, or use force to override.` }
|
|
1079
|
+
// proceeding: settle the RESTING lifecycle (a resumed working agent is now idle), then relaunch iff the agent
|
|
1080
|
+
// is CONFIRMED offline (or force — the wedged-but-alive escape). `starting`/`unknown` fall through to a no-op.
|
|
1081
|
+
writeRecord({ ...wt.rec, status: wt.rec.status === 'active' ? 'idle' : wt.rec.status })
|
|
1082
|
+
if (force || lv === 'offline') {
|
|
1083
|
+
await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
|
|
1084
|
+
await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec)) // resume under the SAME persisted launcher ([[launcher-select]])
|
|
970
1085
|
await waitForReady(id, h) // a relaunched agent is "ready" only once the adapter reads it online
|
|
971
1086
|
}
|
|
972
|
-
return true
|
|
1087
|
+
return { ok: true }
|
|
973
1088
|
}
|
|
974
1089
|
|
|
975
|
-
// agent/human PROPOSAL → awaiting (review = propose merge, done = nothing, close-pending = propose close).
|
|
976
|
-
export async function propose(id: string, proposal: Proposal): Promise<boolean> {
|
|
977
|
-
const wt = await findWorktree(id)
|
|
978
|
-
if (!wt) return false
|
|
979
|
-
writeRecord({ ...wt.rec, status: 'awaiting', proposal })
|
|
980
|
-
void drainQueue() // a proposal frees this session's slot — start the next queued one if any
|
|
981
|
-
return true
|
|
982
|
-
}
|
|
983
1090
|
// @@@ agent-authored state - the agent (forced by gates at boundaries) writes its OWN state; it is the
|
|
984
1091
|
// authority on what a stop MEANS (awaiting human vs parked on a background task). External hooks only know
|
|
985
1092
|
// SOMETHING changed, not the transition, so they force a write, never infer. The session it writes is resolved
|
|
@@ -1064,13 +1171,15 @@ function porcelainPath(line: string): string {
|
|
|
1064
1171
|
// @@@ MANAGER COCKPIT - the review payload (the cockpit's first verb; see the manager-cockpit spec node).
|
|
1065
1172
|
// One server-side bundle that lets a manager (human or agent) decide whether to merge a session WITHOUT
|
|
1066
1173
|
// hand-running git: how far ahead it is, its REAL changes (merge-base diff, never a phantom main..HEAD one),
|
|
1067
|
-
// whether uncommitted non-runtime work remains, the merge/
|
|
1068
|
-
//
|
|
1069
|
-
//
|
|
1070
|
-
//
|
|
1174
|
+
// whether uncommitted non-runtime work remains, the merge/lint gates, and the agent's standing proposal.
|
|
1175
|
+
// ahead/dirty/diff/conflicts are computed against the SESSION's worktree (per id); lint reflects the CLI
|
|
1176
|
+
// package's OWN location (where this runs) — the spec-cli that's actually live. There is deliberately NO
|
|
1177
|
+
// build/typecheck/test gate: whether a change is SOUND is proven by the node's yatsu (measured through the
|
|
1178
|
+
// real product), not by a language-specific automated checker — so the gates stay language-agnostic (git +
|
|
1179
|
+
// the spec↔code graph, which every governed project has, TS or Python or otherwise). null when no session
|
|
1180
|
+
// has that id.
|
|
1071
1181
|
export type ReviewGates = {
|
|
1072
1182
|
conflictsWithMain: boolean // a dry-run merge into main would conflict (in-memory, safe)
|
|
1073
|
-
typecheck: { ok: boolean; errorCount: number } // `tsc --noEmit` on the CLI package
|
|
1074
1183
|
lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
|
|
1075
1184
|
}
|
|
1076
1185
|
export type ReviewPayload = {
|
|
@@ -1082,37 +1191,59 @@ export type ReviewPayload = {
|
|
|
1082
1191
|
proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
|
|
1083
1192
|
}
|
|
1084
1193
|
|
|
1085
|
-
// @@@
|
|
1086
|
-
//
|
|
1087
|
-
//
|
|
1088
|
-
//
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1194
|
+
// @@@ lintGate - the spec↔code graph lint is a LOCATION gate: a function of the backend checkout's tree ALONE
|
|
1195
|
+
// (its .spec graph + governed files), not of which session is reviewed, and it costs a few seconds. Re-running
|
|
1196
|
+
// it on every reviewPayload — i.e. on every [[review-proof]] Proof-tab open, and once per session — is
|
|
1197
|
+
// wasteful, so memoize it on a whole-repo fingerprint: `rev-parse HEAD` + `status --porcelain` + the mtimes of
|
|
1198
|
+
// the changed paths (covers committed state, the dirty SET, and dirty-file CONTENT). An identical fingerprint
|
|
1199
|
+
// reuses the last (in-flight) result — a re-open or a second session's proof is instant — while any commit or
|
|
1200
|
+
// working-tree edit moves the fingerprint and recomputes. A rejected run is not cached.
|
|
1201
|
+
let gateCache: { fp: string; p: Promise<ReviewGates['lint']> } | null = null
|
|
1202
|
+
async function lintGate(): Promise<ReviewGates['lint']> {
|
|
1203
|
+
const root = repoRoot()
|
|
1204
|
+
const [head, status] = await Promise.all([
|
|
1205
|
+
gitA(['-C', root, 'rev-parse', 'HEAD']),
|
|
1206
|
+
gitA(['-C', root, 'status', '--porcelain', '--untracked-files=all']),
|
|
1207
|
+
])
|
|
1208
|
+
// `status --porcelain` gives the SET of changed paths + status letters but is CONTENT-BLIND: re-editing an
|
|
1209
|
+
// already-listed (dirty or untracked) file leaves the string byte-identical, so HEAD+status alone would
|
|
1210
|
+
// freeze the gate after a file first goes dirty. `--untracked-files=all` stops an untracked dir from
|
|
1211
|
+
// collapsing to one line (which hides a newly-added file); then fold each listed path's mtime in, so a
|
|
1212
|
+
// content edit to a dirty file also moves the fingerprint. HEAD covers committed state, this covers the
|
|
1213
|
+
// working tree. (Residual, accepted: the fingerprint is snapshot just before the compute, so a change
|
|
1214
|
+
// landing mid-compute is labelled with the pre-change fp — rare, and the gate is advisory, re-verified at merge.)
|
|
1215
|
+
const mtimes = status.split('\n').filter(Boolean).map(porcelainPath)
|
|
1216
|
+
.map((p) => { try { return statSync(join(root, p)).mtimeMs } catch { return 0 } }).join(',')
|
|
1217
|
+
const fp = head.trim() + '\n' + status + '\n' + mtimes
|
|
1218
|
+
if (gateCache?.fp === fp) return gateCache.p
|
|
1219
|
+
const p = (async () => {
|
|
1220
|
+
const { specLint } = await import('./lint.js')
|
|
1221
|
+
const findings = await specLint()
|
|
1222
|
+
return {
|
|
1223
|
+
errorCount: findings.filter((f) => f.level === 'error').length,
|
|
1224
|
+
warningCount: findings.filter((f) => f.level === 'warn').length,
|
|
1225
|
+
}
|
|
1226
|
+
})()
|
|
1227
|
+
p.catch(() => { if (gateCache?.p === p) gateCache = null }) // don't pin a failed run
|
|
1228
|
+
gateCache = { fp, p }
|
|
1229
|
+
return p
|
|
1098
1230
|
}
|
|
1099
1231
|
|
|
1100
|
-
// @@@ reviewPayload - assemble the cockpit review for one session. The
|
|
1101
|
-
// (ahead / dirty / diff / conflict gate) plus the
|
|
1102
|
-
//
|
|
1103
|
-
//
|
|
1232
|
+
// @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
|
|
1233
|
+
// (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
|
|
1234
|
+
// in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
|
|
1235
|
+
// so an unchanged tree doesn't re-run the lint on each review / Proof-tab open, while any commit or edit
|
|
1236
|
+
// invalidates and recomputes.
|
|
1104
1237
|
export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
1105
1238
|
const wt = await findWorktree(id)
|
|
1106
1239
|
if (!wt) return null
|
|
1107
|
-
const { specLint } = await import('./lint.js')
|
|
1108
1240
|
const base = mainBranch()
|
|
1109
|
-
const [aheadOut, statusOut, diff, conflictsWithMain,
|
|
1241
|
+
const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
|
|
1110
1242
|
gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
|
|
1111
1243
|
gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
|
|
1112
1244
|
mergeBaseDiff(wt.path, base),
|
|
1113
1245
|
mergeConflicts(wt.path, base),
|
|
1114
|
-
|
|
1115
|
-
specLint(),
|
|
1246
|
+
lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
|
|
1116
1247
|
])
|
|
1117
1248
|
// the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
|
|
1118
1249
|
// path is genuine work — this is just the total uncommitted count.
|
|
@@ -1121,13 +1252,7 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
1121
1252
|
id, node: wt.rec.node, branch: wt.branch,
|
|
1122
1253
|
ahead: Number(aheadOut.trim()) || 0,
|
|
1123
1254
|
dirtyNonRuntime, diff,
|
|
1124
|
-
gates: {
|
|
1125
|
-
conflictsWithMain, typecheck,
|
|
1126
|
-
lint: {
|
|
1127
|
-
errorCount: findings.filter((f) => f.level === 'error').length,
|
|
1128
|
-
warningCount: findings.filter((f) => f.level === 'warn').length,
|
|
1129
|
-
},
|
|
1130
|
-
},
|
|
1255
|
+
gates: { conflictsWithMain, lint },
|
|
1131
1256
|
proposal: { kind: wt.rec.proposal, note: wt.rec.note },
|
|
1132
1257
|
}
|
|
1133
1258
|
}
|
|
@@ -1150,8 +1275,9 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
|
|
|
1150
1275
|
|
|
1151
1276
|
// @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
|
|
1152
1277
|
// SESSION'S OWN agent lands the merge, never the server (it carries no `git merge` logic and never touches
|
|
1153
|
-
// main's tree). It reopens the session (clears the proposal
|
|
1278
|
+
// main's tree). It reopens the session (clears the proposal, `--resume`s via reopen if tmux died —
|
|
1154
1279
|
// which waits for the rendezvous socket, closing the just-relaunched-no-socket race) and dispatches mergePrompt
|
|
1280
|
+
// — that delivered prompt flips the lifecycle to active regardless of reopen's resting state
|
|
1155
1281
|
// through sendKeys. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
|
|
1156
1282
|
// the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
|
|
1157
1283
|
// CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
|
|
@@ -1160,7 +1286,10 @@ export async function mergeSession(id: string): Promise<{ dispatched: boolean; r
|
|
|
1160
1286
|
const wt = await findWorktree(id)
|
|
1161
1287
|
if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
|
|
1162
1288
|
const branch = wt.branch, main = mainRoot()
|
|
1163
|
-
|
|
1289
|
+
// ensure-live, NOT the guarded human relaunch: an already-online agent is reused (the merge prompt just needs
|
|
1290
|
+
// a live socket), and only a confirmed-offline one is relaunched — so merge never refuses on a live agent.
|
|
1291
|
+
const re = await reopen(id, { guard: false })
|
|
1292
|
+
if (!re.ok) return { dispatched: false, reason: re.error || 'could not reopen session' }
|
|
1164
1293
|
const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', branch])).trim()
|
|
1165
1294
|
const reason = subject.replace(/^spec:\s+/, '') || branch
|
|
1166
1295
|
const r = await sendKeys(id, mergePrompt(main, branch, reason))
|
|
@@ -1237,10 +1366,10 @@ export async function captureSessionResult(id: string): Promise<CaptureResult> {
|
|
|
1237
1366
|
// @@@ presentation + selection - shared by `spex ls` (pretty), `spex watch` (events) and the API.
|
|
1238
1367
|
export const STATUS_GLYPH: Record<DisplayStatus, string> = {
|
|
1239
1368
|
working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
|
|
1240
|
-
'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc',
|
|
1369
|
+
'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc', unknown: '\u2047',
|
|
1241
1370
|
}
|
|
1242
1371
|
const ANSI: Record<DisplayStatus, string> = {
|
|
1243
|
-
working: '33', idle: '90', offline: '90', starting: '36', review: '35', done: '34', 'close-pending': '31', parked: '36', error: '31', asking: '93', queued: '90',
|
|
1372
|
+
working: '33', idle: '90', offline: '90', starting: '36', review: '35', done: '34', 'close-pending': '31', parked: '36', error: '31', asking: '93', queued: '90', unknown: '93',
|
|
1244
1373
|
}
|
|
1245
1374
|
|
|
1246
1375
|
// @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
|
|
@@ -1249,7 +1378,13 @@ const ANSI: Record<DisplayStatus, string> = {
|
|
|
1249
1378
|
// resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
|
|
1250
1379
|
// review/merge/send/close act on".
|
|
1251
1380
|
export function matchesSelector(s: Session, q: string): boolean {
|
|
1252
|
-
|
|
1381
|
+
// a selector may be a comma-separated list (the same convention as `--status a,b`): it matches iff ANY part
|
|
1382
|
+
// names the session, so `watch a,b` and `watch a b` are equivalent. A single name is the one-part case. This
|
|
1383
|
+
// is what stops a comma-joined selector from silently matching nothing — an id/node/branch never holds a
|
|
1384
|
+
// comma, so without the split `a,b` would be one literal selector that matches no session and streams in
|
|
1385
|
+
// silence forever.
|
|
1386
|
+
return q.split(',').map((p) => p.trim()).filter(Boolean)
|
|
1387
|
+
.some((p) => s.id === p || s.id.startsWith(p) || s.node === p || s.branch === p)
|
|
1253
1388
|
}
|
|
1254
1389
|
|
|
1255
1390
|
// no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
|
|
@@ -1311,9 +1446,9 @@ export function formatTable(sessions: Session[], color = true): string {
|
|
|
1311
1446
|
|
|
1312
1447
|
const WATCH_ACTIONABLE = new Set<DisplayStatus>(['review', 'done', 'close-pending', 'offline', 'error', 'asking'])
|
|
1313
1448
|
const NEXT: Record<string, string> = {
|
|
1314
|
-
review: 'merge |
|
|
1315
|
-
done: 'merge |
|
|
1316
|
-
'close-pending': 'close
|
|
1449
|
+
review: 'merge | close',
|
|
1450
|
+
done: 'merge | close',
|
|
1451
|
+
'close-pending': 'close',
|
|
1317
1452
|
offline: 'reopen (relaunch & resume)',
|
|
1318
1453
|
error: 'reopen (relaunch & retry) | capture | close',
|
|
1319
1454
|
asking: 'send "<msg>" | capture',
|
|
@@ -1354,6 +1489,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1354
1489
|
const deadline = until ? Date.now() + Math.max(1000, until.timeoutMs) : 0
|
|
1355
1490
|
const isActionable = (st: DisplayStatus) => WATCH_ACTIONABLE.has(st) || (includeIdle && st === 'idle')
|
|
1356
1491
|
let warnedDown = false
|
|
1492
|
+
let downMsg: string | null = null // set while the backend is unreachable, cleared on a good poll; the deadline reports it
|
|
1357
1493
|
for (;;) {
|
|
1358
1494
|
try {
|
|
1359
1495
|
// EXISTENCE is the selector-matched board across ALL statuses — listSessions now lists every worktree
|
|
@@ -1362,7 +1498,7 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1362
1498
|
// emit, never whether a session is present — using it for presence would read a status change out of the
|
|
1363
1499
|
// filtered set as a (false) removal.
|
|
1364
1500
|
const all = selectSessions(await source(), selectors)
|
|
1365
|
-
warnedDown = false // a successful poll re-arms the down-warning
|
|
1501
|
+
warnedDown = false; downMsg = null // a successful poll re-arms the down-warning (and clears the deadline's down-report)
|
|
1366
1502
|
const ids = new Set(all.map((s) => s.id))
|
|
1367
1503
|
const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
|
|
1368
1504
|
for (const s of all) {
|
|
@@ -1389,23 +1525,33 @@ export async function watchSessions(emit: (line: string) => void, opts: WatchOpt
|
|
|
1389
1525
|
if (!all.length) return { gone: true }
|
|
1390
1526
|
}
|
|
1391
1527
|
} catch (e) {
|
|
1392
|
-
// a backend
|
|
1393
|
-
//
|
|
1394
|
-
//
|
|
1395
|
-
|
|
1396
|
-
|
|
1528
|
+
// a backend error in the poll must NOT be swallowed AND must NOT emit a false `closed` for every session:
|
|
1529
|
+
// we skip the tick (prev is untouched → no phantom removals). Two shapes of BackendError diverge here:
|
|
1530
|
+
// • REACHABLE but erroring (an HTTP status) — the backend answered and is broken: a real terminal
|
|
1531
|
+
// condition, so a bounded `wait` fails loud IMMEDIATELY rather than spinning out its whole timeout.
|
|
1532
|
+
// • UNREACHABLE (no status — ECONNREFUSED / fetch failed) — nothing is listening, e.g. the supervisor
|
|
1533
|
+
// is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
|
|
1534
|
+
// record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
|
|
1535
|
+
// backgrounded `spex wait` survives the ~1s restart instead of dying on the interrupted fetch.
|
|
1536
|
+
if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message }
|
|
1537
|
+
if (isBackendDown(e)) {
|
|
1538
|
+
downMsg = (e as Error).message
|
|
1539
|
+
if (!warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${downMsg}; retrying every ${intervalMs / 1000}s…`) }
|
|
1540
|
+
}
|
|
1397
1541
|
}
|
|
1398
|
-
// the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep:
|
|
1399
|
-
//
|
|
1400
|
-
|
|
1542
|
+
// the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep: this
|
|
1543
|
+
// guarantees `spex wait` can never hang on a worker stuck outside WATCH_ACTIONABLE — nor spin forever on a
|
|
1544
|
+
// backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
|
|
1545
|
+
// not a false "no actionable status" timeout, so the manager sees the honest cause.
|
|
1546
|
+
if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg } : { timedOut: true }
|
|
1401
1547
|
await sleep(intervalMs)
|
|
1402
1548
|
}
|
|
1403
1549
|
}
|
|
1404
1550
|
|
|
1405
1551
|
// @@@ sendKeys - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
|
|
1406
|
-
// ([[harness-adapter]]) — claude the rendezvous control socket (
|
|
1407
|
-
// app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
|
|
1408
|
-
// delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
|
|
1552
|
+
// ([[harness-adapter]]) — claude the rendezvous control socket (optimistic-after-liveness: the reply line flushes
|
|
1553
|
+
// to a live socket), codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
|
|
1554
|
+
// fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
|
|
1409
1555
|
// ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
|
|
1410
1556
|
// instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
|
|
1411
1557
|
// harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
@@ -1441,8 +1587,9 @@ const TMUX_KEY: Record<string, string> = {
|
|
|
1441
1587
|
// literal text "S-Enter" etc. (and shift is a no-op there anyway), so a stray S- is dropped. Shift+Tab is
|
|
1442
1588
|
// the named exception: tmux spells it `BTab` (back-tab → ESC[Z, what Claude Code's mode-cycle reads).
|
|
1443
1589
|
const SHIFTABLE = new Set(['Up', 'Down', 'Left', 'Right', 'Home', 'End', 'DC'])
|
|
1444
|
-
|
|
1445
|
-
|
|
1590
|
+
// resolve ONE frontend token to the `tmux send-keys` args for it, or null if it isn't a known base after its
|
|
1591
|
+
// prefixes (defends the send-keys arg). Pure — the batch loop below sequences the actual sends.
|
|
1592
|
+
function rawKeyArgs(id: string, key: string): string[] | null {
|
|
1446
1593
|
// peel the optional C-/M-/S- modifier prefixes (each at most once, in any order) off the front; the
|
|
1447
1594
|
// remainder is the BASE key. The frontend only ever sends {C-,M-,S-} prefixes + a named key or one char.
|
|
1448
1595
|
let rest = key, prefix = ''
|
|
@@ -1457,13 +1604,29 @@ export async function rawKey(id: string, key: string): Promise<boolean> {
|
|
|
1457
1604
|
if (prefix.includes('S-') && named === 'Tab') token = noShift + 'BTab' // Shift+Tab → back-tab
|
|
1458
1605
|
else if (prefix.includes('S-') && !SHIFTABLE.has(named)) token = noShift + named // tmux can't carry S- here
|
|
1459
1606
|
else token = prefix + named
|
|
1460
|
-
|
|
1607
|
+
return ['send-keys', '-t', id, token]
|
|
1461
1608
|
}
|
|
1462
1609
|
if ([...rest].length === 1) {
|
|
1463
1610
|
// a single printable char: bare → literal (`-l`, so tmux never reinterprets it as a key name);
|
|
1464
1611
|
// modified → hand tmux the `C-`/`M-`/`S-` combo to parse (e.g. `C-a`), which `-l` would defeat.
|
|
1465
|
-
if (prefix)
|
|
1466
|
-
|
|
1612
|
+
if (prefix) return ['send-keys', '-t', id, prefix + rest]
|
|
1613
|
+
return ['send-keys', '-t', id, '-l', '--', rest]
|
|
1614
|
+
}
|
|
1615
|
+
return null
|
|
1616
|
+
}
|
|
1617
|
+
// One call carries a BATCH of tokens (or one) — the client coalesces fast typing into an ordered array. Order
|
|
1618
|
+
// is the whole point ([[nav-mode-key-ordering]]): the keys are sent by ONE awaited `send-keys` each, IN ARRAY
|
|
1619
|
+
// ORDER, so they reach the pane in exactly the order they were struck. Concurrent per-key POSTs used to race
|
|
1620
|
+
// (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
|
|
1621
|
+
// An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
|
|
1622
|
+
export async function rawKey(id: string, key: string | string[]): Promise<boolean> {
|
|
1623
|
+
const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
|
|
1624
|
+
if (list.length === 0 || !(await alive(id))) return false
|
|
1625
|
+
let sent = false
|
|
1626
|
+
for (const k of list) {
|
|
1627
|
+
const args = rawKeyArgs(id, k)
|
|
1628
|
+
if (!args) continue
|
|
1629
|
+
await tmux(args); sent = true
|
|
1467
1630
|
}
|
|
1468
|
-
return
|
|
1631
|
+
return sent
|
|
1469
1632
|
}
|