spexcode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/spex.mjs +15 -0
- package/dashboard-dist/assets/index-B60MILFg.js +139 -0
- package/dashboard-dist/assets/index-Cq7hwngj.css +32 -0
- package/dashboard-dist/index.html +16 -0
- package/package.json +35 -0
- package/src/board.ts +119 -0
- package/src/cli.ts +487 -0
- package/src/client.ts +102 -0
- package/src/gateway.ts +241 -0
- package/src/git.ts +492 -0
- package/src/guide.ts +134 -0
- package/src/harness.ts +674 -0
- package/src/hooks.ts +41 -0
- package/src/index.ts +233 -0
- package/src/init.ts +120 -0
- package/src/layout.ts +246 -0
- package/src/lint.ts +206 -0
- package/src/login-page.ts +79 -0
- package/src/materialize.ts +85 -0
- package/src/pty-bridge.ts +235 -0
- package/src/ranker.ts +129 -0
- package/src/resilience.ts +41 -0
- package/src/search.bench.mjs +47 -0
- package/src/search.ts +24 -0
- package/src/self.ts +256 -0
- package/src/sessions.ts +1469 -0
- package/src/slash-commands.ts +242 -0
- package/src/specs.ts +331 -0
- package/src/supervise.ts +158 -0
- package/src/uploads.ts +31 -0
- package/templates/hooks/pre-commit +57 -0
- package/templates/hooks/prepare-commit-msg +14 -0
- package/templates/spec/project/.config/core/idle/idle.sh +15 -0
- package/templates/spec/project/.config/core/idle/spec.md +13 -0
- package/templates/spec/project/.config/core/mark-active/mark-active.sh +46 -0
- package/templates/spec/project/.config/core/mark-active/spec.md +16 -0
- package/templates/spec/project/.config/core/session-fail/fail.sh +12 -0
- package/templates/spec/project/.config/core/session-fail/spec.md +13 -0
- package/templates/spec/project/.config/core/spec-first/spec-first.sh +54 -0
- package/templates/spec/project/.config/core/spec-first/spec.md +15 -0
- package/templates/spec/project/.config/core/spec-of-file/spec-of-file.sh +42 -0
- package/templates/spec/project/.config/core/spec-of-file/spec.md +15 -0
- package/templates/spec/project/.config/core/spec.md +13 -0
- package/templates/spec/project/.config/core/stop-gate/spec.md +17 -0
- package/templates/spec/project/.config/core/stop-gate/stop-gate.sh +108 -0
- package/templates/spec/project/.config/extract/spec.md +60 -0
- package/templates/spec/project/.config/forge-link/spec.md +9 -0
- package/templates/spec/project/.config/memory-hygiene/spec.md +15 -0
- package/templates/spec/project/.config/regroup/spec.md +25 -0
- package/templates/spec/project/.config/scenario/spec.md +32 -0
- package/templates/spec/project/.config/spec.md +15 -0
- package/templates/spec/project/.config/supervisor/spec.md +8 -0
- package/templates/spec/project/.config/tidy/spec.md +25 -0
- package/templates/spec/project/spec.md +19 -0
- package/templates/spexcode.json +5 -0
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,1469 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process'
|
|
2
|
+
import { promisify } from 'node:util'
|
|
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'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, type ReviewDiffFile } from './git.js'
|
|
8
|
+
import { loadSpecs } from './specs.js'
|
|
9
|
+
import { defaultHarness, harnessById, rvSock, type Harness, type DispatchResult } from './harness.js'
|
|
10
|
+
import { materialize } from './materialize.js'
|
|
11
|
+
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, readAliasedRawRecord, envSessionId, type RawRecord } from './layout.js'
|
|
12
|
+
|
|
13
|
+
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
14
|
+
// SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
|
|
15
|
+
// session_id (NOT a worktree file — the worktree stays pristine), surviving a kill / reboot / moving the
|
|
16
|
+
// folder. We launch claude with `--session-id <id>` (id we choose) so the SAME conversation can be
|
|
17
|
+
// `--resume`d into a fresh tmux. NO in-memory map: listSessions() ENUMERATES that store every time.
|
|
18
|
+
//
|
|
19
|
+
// STATE MACHINE — two ORTHOGONAL axes (see [[state]]): an agent-authored LIFECYCLE and a runtime-derived
|
|
20
|
+
// LIVENESS, neither overriding the other.
|
|
21
|
+
// lifecycle (authored): active | idle | awaiting | parked | error | asking | queued. `idle` is the ONE
|
|
22
|
+
// inferred one (the Notification(idle_prompt) hook, guarded active-only so it never clobbers a
|
|
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, or the
|
|
25
|
+
// harness online-signal (claude's rendezvous socket) is gone past the boot grace; starting =
|
|
26
|
+
// the boot window. reconcile composes the two into the compact DisplayStatus for one-glyph surfaces.
|
|
27
|
+
// awaiting → the agent's PROPOSAL, awaiting a human:
|
|
28
|
+
// proposal=merge → shown "review" ("ready, merge me")
|
|
29
|
+
// proposal=nothing → shown "done" ("finished, your call")
|
|
30
|
+
// proposal=close → shown "close-pending" ("I suggest discarding this worktree")
|
|
31
|
+
// asking → the agent is pausing to ask the HUMAN a question. Written DETERMINISTICALLY two ways: the
|
|
32
|
+
// mark-active PreToolUse hook captures it the moment the agent invokes the AskUserQuestion
|
|
33
|
+
// tool (question → note), and the agent may also declare it via `spex session ask --note
|
|
34
|
+
// <question>`. Not inferred. Distinct from `parked` (which waits on a background task/
|
|
35
|
+
// schedule and self-resumes); an asking agent resumes only when a human sends it a prompt.
|
|
36
|
+
// queued → a prepared worktree held below the concurrency cap; the drainer launches it as a slot frees.
|
|
37
|
+
// (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
|
+
// via reopen() → active. `merges` is METADATA (how many times merged), shown as a badge, not a state.
|
|
40
|
+
//
|
|
41
|
+
// Launch rules (CLAUDE.md / memory): private `tmux -L <label>` socket + `--dangerously-skip-permissions`.
|
|
42
|
+
// SPEXCODE_TMUX / SPEXCODE_CLAUDE_CMD override both for tests.
|
|
43
|
+
|
|
44
|
+
const pexec = promisify(execFile)
|
|
45
|
+
const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
46
|
+
// the harness the dashboard/CLI launcher drives. ALL harness-specific launch facts (the agent command, the
|
|
47
|
+
// session-id flag, the hook shim, the session env var) come from this adapter — the launcher never names
|
|
48
|
+
// Claude. Resolved once here ([[harness-adapter]]); a future codex launcher flips defaultHarness, nothing else.
|
|
49
|
+
const HARNESS = defaultHarness
|
|
50
|
+
const COLS = 120, ROWS = 32
|
|
51
|
+
// @@@ concurrency cap - the most working agents we let run AT ONCE. Heavy multi-agent load (many claude
|
|
52
|
+
// processes computing simultaneously) was the source of resource-pressure crashes, so a launch beyond the
|
|
53
|
+
// cap is QUEUED, not started: it becomes a durable `queued` worktree that the drainer launches the moment a
|
|
54
|
+
// slot frees (an agent stops working/dies). NOT hardcoded — configured PER PROJECT in `spexcode.json`
|
|
55
|
+
// (`sessions.maxActive`), so a box can be tuned to its capacity without touching the toolchain. Precedence:
|
|
56
|
+
// spexcode.json → `SPEXCODE_MAX_ACTIVE` env → default 6. Read LIVE (cheap file read) so an edit takes effect
|
|
57
|
+
// on the next drain tick, no restart. Floored at 1 so a bad value can't wedge the queue to 0.
|
|
58
|
+
function maxActive(): number {
|
|
59
|
+
let v: number | undefined
|
|
60
|
+
try {
|
|
61
|
+
const fromJson = readConfig(mainRoot()).sessions?.maxActive
|
|
62
|
+
if (typeof fromJson === 'number' && Number.isFinite(fromJson)) v = fromJson
|
|
63
|
+
} catch { /* config unreadable — fall through to env/default */ }
|
|
64
|
+
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 ?? 6))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// the rendezvous control socket path + its prompt-delivery/liveness logic now live in the [[harness-adapter]]
|
|
69
|
+
// (claude OWNS the rendezvous; codex does not), so product code asks the adapter rather than hard-wiring it.
|
|
70
|
+
// rvSock is imported only for the two NON-delivery uses that remain product-level: building the launch env var
|
|
71
|
+
// (rvEnv, below) and the best-effort socket sweep on close.
|
|
72
|
+
// env prefix put in front of the spawned agent so it creates this session's rendezvous control socket — and
|
|
73
|
+
// so its hooks + materialize render to the SAME store the backend uses. SPEXCODE_HOME/CODEX_HOME are
|
|
74
|
+
// propagated when set, because the session inherits the tmux SERVER's env (not the backend's), so without this
|
|
75
|
+
// an overridden home would silently leak the session's hook-state + codex-trust to the default ~/.spexcode /
|
|
76
|
+
// ~/.codex. Deterministic: the session's store = the backend's store, never the ambient env's.
|
|
77
|
+
const rvEnv = (id: string, harness = HARNESS) => {
|
|
78
|
+
// SPEXCODE_SESSION_ID is the GOVERNED record id every hook resolves (hp_session_id prefers it) — it makes a
|
|
79
|
+
// codex session, whose own thread id is un-pinnable, still feed its governed record; harmless for claude
|
|
80
|
+
// (= its pinned id). The CLAUDE_BG rendezvous control socket is the reclaude prompt-delivery path and exists
|
|
81
|
+
// ONLY for harnesses that own one (claude) — codex has no such daemon, so it's omitted there.
|
|
82
|
+
const parts = [`SPEXCODE_SESSION_ID=${id}`]
|
|
83
|
+
if (harness.ownsRendezvous) parts.push(`CLAUDE_BG_BACKEND=daemon`, `CLAUDE_BG_RENDEZVOUS_SOCK=${rvSock(id)}`)
|
|
84
|
+
for (const v of ['SPEXCODE_HOME', 'CODEX_HOME']) { const val = process.env[v]; if (val) parts.push(`${v}=${val}`) }
|
|
85
|
+
return parts.join(' ')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// the prompt-dispatch outcome type + its claude/codex delivery implementations live in the [[harness-adapter]]
|
|
89
|
+
// (each harness OWNS its input channel — claude the rendezvous socket, codex app-server JSON-RPC). Re-exported here
|
|
90
|
+
// for the existing importers (client.ts) that read it off the sessions module.
|
|
91
|
+
export type { DispatchResult }
|
|
92
|
+
|
|
93
|
+
export type Lifecycle = 'active' | 'idle' | 'awaiting' | 'parked' | 'error' | 'asking' | 'queued'
|
|
94
|
+
export type Proposal = 'merge' | 'nothing' | 'close'
|
|
95
|
+
export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued'
|
|
96
|
+
// liveness — the orthogonal axis to Lifecycle: whether the agent process is actually up, derived (never
|
|
97
|
+
// authored) for EVERY session regardless of its lifecycle. See [[state]]: lifecycle and liveness never
|
|
98
|
+
// override each other; the UI keys the terminal-mount / relaunch panel on this, the badge on lifecycle.
|
|
99
|
+
export type Liveness = 'online' | 'starting' | 'offline'
|
|
100
|
+
const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', nothing: 'done', close: 'close-pending' }
|
|
101
|
+
|
|
102
|
+
export type Session = {
|
|
103
|
+
id: string; node: string | null; title: string | null; name: string | null; branch: string | null; path: string
|
|
104
|
+
harness: string // which harness (claude|codex) runs this session — carried so liveness/occupancy route through its adapter
|
|
105
|
+
lifecycle: Lifecycle; proposal: Proposal | null; merges: number; status: DisplayStatus; liveness: Liveness; note: string | null
|
|
106
|
+
prompt: string | null; promptPreview: string | null; created: number; activity: string | null
|
|
107
|
+
sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ensure a session's GLOBAL store dir exists, returning its path. Idempotent (recursive mkdir) — every
|
|
111
|
+
// writer that drops an artifact (record/prompt/launch/launch.sh/comms) calls this first so order never matters.
|
|
112
|
+
function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
|
|
113
|
+
|
|
114
|
+
// @@@ originating prompt - what the session was ASKED to do, captured at launch so a manager (human or
|
|
115
|
+
// agent) can later answer "what was this session for?" WITHOUT transcript archaeology. Prompts are
|
|
116
|
+
// multi-line, so they live as their own artifact (`prompt`) in the session's GLOBAL store dir (keyed by
|
|
117
|
+
// session_id, [[state]]), never in the worktree. Everything here is BEST-EFFORT: a missing artifact (a
|
|
118
|
+
// session launched before this existed) just means no prompt is shown — never an error, never blocks a launch.
|
|
119
|
+
function writePromptFile(id: string, prompt: string): void {
|
|
120
|
+
try { writeFileSync(join(storeDir(id), 'prompt'), prompt) } catch { /* best-effort; must never block the launch */ }
|
|
121
|
+
}
|
|
122
|
+
function readPromptFile(id: string): string | null {
|
|
123
|
+
try {
|
|
124
|
+
const p = sessionArtifactPath(id, 'prompt')
|
|
125
|
+
if (!existsSync(p)) return null
|
|
126
|
+
const s = readFileSync(p, 'utf8')
|
|
127
|
+
return s.trim() ? s : null
|
|
128
|
+
} catch { return null }
|
|
129
|
+
}
|
|
130
|
+
// @@@ deferred launch prompt - a QUEUED session is a fully-prepared worktree we have NOT launched claude
|
|
131
|
+
// into yet. The exact prompt to launch it with — the directive-generated finish-the-op prompt, or the plain
|
|
132
|
+
// human prompt — is parked as the `launch` artifact in the store dir so the drainer can launch it later
|
|
133
|
+
// (possibly after a backend restart) WITHOUT re-deriving anything. CONSUMED (removed) the moment the session
|
|
134
|
+
// launches, so it exists only while the session waits in the queue. Distinct from `prompt` (the originating ask).
|
|
135
|
+
function writeLaunchFile(id: string, prompt: string): void {
|
|
136
|
+
try { writeFileSync(join(storeDir(id), 'launch'), prompt) } catch { /* best-effort; the drainer treats a missing file as nothing-to-launch */ }
|
|
137
|
+
}
|
|
138
|
+
function readLaunchFile(id: string): string | null {
|
|
139
|
+
try { const p = sessionArtifactPath(id, 'launch'); return existsSync(p) ? readFileSync(p, 'utf8') : null } catch { return null }
|
|
140
|
+
}
|
|
141
|
+
function removeLaunchFile(id: string): void {
|
|
142
|
+
try { rmSync(sessionArtifactPath(id, 'launch'), { force: true }) } catch { /* best-effort */ }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// a one-line preview of the originating prompt for tables/events: first non-empty line, truncated.
|
|
146
|
+
function promptPreview(prompt: string, n = 60): string {
|
|
147
|
+
const first = prompt.split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
148
|
+
return first.length > n ? first.slice(0, n - 1) + '…' : first
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// the STABLE human label for a session row: a user-chosen NAME (the rename override) wins over everything;
|
|
152
|
+
// else the spec node it references, else a prompt-derived title (node-agnostic sessions), else the branch,
|
|
153
|
+
// else the id. Stable across turns — used for tables/selectors. The frontend mirrors this (session.js
|
|
154
|
+
// sessionName).
|
|
155
|
+
export const sessionLabel = (s: Session): string => s.name || s.node || s.title || s.branch || s.id
|
|
156
|
+
|
|
157
|
+
// @@@ sessionHeadline - the cross-surface HEADLINE: the SAME chain the board card shows (frontend session.js
|
|
158
|
+
// `sessionHeadline`). A user-chosen NAME wins, else the worker's LIVE self-summary (`activity`, the Claude
|
|
159
|
+
// Code pane title — see [[session-activity]]), else a fuller prompt preview, else node/title/branch/id. Use
|
|
160
|
+
// it wherever a session is NAMED FOR A HUMAN in CROSS-SESSION comms (the reply-channel footer, the watch
|
|
161
|
+
// greeting), so an agent recognises a peer the way it reads the board — NOT the bare 7-word prompt
|
|
162
|
+
// truncation `title` that `sessionLabel` stops at. `sessionLabel` stays the stable name for tables/selectors.
|
|
163
|
+
export const sessionHeadline = (s: Session): string => s.name || s.activity || s.promptPreview || s.node || s.title || s.branch || s.id
|
|
164
|
+
|
|
165
|
+
async function tmux(args: string[]): Promise<string> {
|
|
166
|
+
const { stdout } = await pexec('tmux', ['-L', TMUX_SOCK, ...args], { encoding: 'utf8' })
|
|
167
|
+
return stdout
|
|
168
|
+
}
|
|
169
|
+
async function tmuxOk(args: string[]): Promise<boolean> { try { await tmux(args); return true } catch { return false } }
|
|
170
|
+
export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-session', '-t', id]) }
|
|
171
|
+
|
|
172
|
+
// worktrees + branches are created off MAIN even when the server runs inside a worktree.
|
|
173
|
+
function mainRoot(): string {
|
|
174
|
+
try { return dirname(gitCommonDir()) }
|
|
175
|
+
catch { return repoRoot() }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// @@@ pkgRoot - the CLI package's OWN directory, derived from this module's location, never a hardcoded
|
|
179
|
+
// repoRoot()+'spec-cli'. This file lives at <pkgRoot>/src/sessions.ts, so `..` from it is the package
|
|
180
|
+
// root — making the launch-script paths (hooks/, node_modules/.bin/tsx, src/cli.ts) survive the package
|
|
181
|
+
// being renamed or relocated out of the default <repo>/spec-cli layout.
|
|
182
|
+
function pkgRoot(): string {
|
|
183
|
+
return fileURLToPath(new URL('..', import.meta.url))
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// the in-memory session record — the typed view of session.json. `governed` (dashboard-launched=true vs
|
|
187
|
+
// user-self-launched=false), `worktreePath`/`branch`/`createdAt` are the fields the board USED to read off
|
|
188
|
+
// the worktree (its path/birthtime); now they live IN the record, since the record is the enumeration source.
|
|
189
|
+
// `name` is the rename override (distinct from the prompt-derived `title`); `session` is the harness session_id
|
|
190
|
+
// (the store key). The launcher mints the id (`claude --session-id <id>`) so it equals what every hook payload
|
|
191
|
+
// and CLAUDE_CODE_SESSION_ID carry — one id across the record dir, tmux window, rendezvous socket, and commits.
|
|
192
|
+
type SessRec = {
|
|
193
|
+
session: string; governed: boolean; worktreePath: string; branch: string | null
|
|
194
|
+
node: string | null; title: string | null; name: string | null
|
|
195
|
+
status: Lifecycle; proposal: Proposal | null; merges: number; note: string | null
|
|
196
|
+
sortKey: number | null; createdAt: number; harness: string; harnessSessionId: string | null
|
|
197
|
+
}
|
|
198
|
+
const LIFECYCLES = new Set<Lifecycle>(['active', 'idle', 'awaiting', 'parked', 'error', 'asking', 'queued'])
|
|
199
|
+
const PROPOSALS = new Set<Proposal>(['merge', 'nothing', 'close'])
|
|
200
|
+
|
|
201
|
+
// typed read of a session's record from the global store (null if it has none — a self-launched session that
|
|
202
|
+
// only ever wrote spec-discipline sentinels has a store dir but no session.json). Goes through layout's
|
|
203
|
+
// readAliasedRawRecord (the seam that owns the path + the codex-thread-id alias), then validates the loose
|
|
204
|
+
// on-disk fields into the typed shape — so a codex hook resolving by its thread id reaches the real record.
|
|
205
|
+
function readRecord(id: string): SessRec | null {
|
|
206
|
+
const raw = readAliasedRawRecord(id)
|
|
207
|
+
if (!raw) return null
|
|
208
|
+
return fromRaw(raw)
|
|
209
|
+
}
|
|
210
|
+
function fromRaw(raw: RawRecord): SessRec {
|
|
211
|
+
const status = LIFECYCLES.has(raw.status as Lifecycle) ? raw.status as Lifecycle : 'active'
|
|
212
|
+
const proposal = raw.proposal && PROPOSALS.has(raw.proposal as Proposal) ? raw.proposal as Proposal : null
|
|
213
|
+
const sk = raw.sortkey
|
|
214
|
+
const sortKey = typeof sk === 'number' && Number.isFinite(sk) ? sk : null
|
|
215
|
+
return {
|
|
216
|
+
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,
|
|
218
|
+
status, proposal, merges: Number(raw.merges) || 0, note: raw.note || null, sortKey, createdAt: Number(raw.createdAt) || 0,
|
|
219
|
+
harness: raw.harness || 'claude', // records written before the harness field default to claude
|
|
220
|
+
harnessSessionId: raw.harness_session_id || null,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
// @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
|
|
224
|
+
// present (nulls rendered as "" / the empty value, never an absent key). That stable shape is the contract the
|
|
225
|
+
// pure-shell hot-path hook (mark-active) relies on: it value-replaces `"status"`/`"proposal"`/`"note"` with a
|
|
226
|
+
// single sed and never needs jq on the user's box. So do NOT switch to conditional keys or a compact dump.
|
|
227
|
+
function writeRecord(rec: SessRec): void {
|
|
228
|
+
const obj = {
|
|
229
|
+
session_id: rec.session,
|
|
230
|
+
governed: rec.governed,
|
|
231
|
+
worktree_path: rec.worktreePath,
|
|
232
|
+
branch: rec.branch ?? '',
|
|
233
|
+
node: rec.node ?? '',
|
|
234
|
+
title: rec.title ?? '',
|
|
235
|
+
name: rec.name ?? '',
|
|
236
|
+
status: rec.status,
|
|
237
|
+
proposal: rec.proposal ?? '',
|
|
238
|
+
merges: rec.merges,
|
|
239
|
+
note: rec.note ?? '',
|
|
240
|
+
sortkey: rec.sortKey ?? '',
|
|
241
|
+
createdAt: rec.createdAt,
|
|
242
|
+
harness: rec.harness || 'claude',
|
|
243
|
+
harness_session_id: rec.harnessSessionId ?? '',
|
|
244
|
+
}
|
|
245
|
+
mkdirSync(sessionStoreDir(rec.session), { recursive: true })
|
|
246
|
+
writeFileSync(sessionRecordPath(rec.session), JSON.stringify(obj, null, 2) + '\n')
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// @@@ fail-loud enumeration - the worktree set is the board's EXISTENCE truth, so a failed enumeration must
|
|
250
|
+
// NEVER masquerade as an empty repo. `gitA` swallows a git error to '' (→ zero rows), which a caller would
|
|
251
|
+
// read as "every worktree was removed" — exactly the false mass-`closed` watchSessions would emit once the
|
|
252
|
+
// flicker debounce is gone. `git worktree list` ALWAYS lists at least the main worktree, so an ok run with
|
|
253
|
+
// zero `worktree ` lines is itself a failure. Both cases THROW; the caller (listSessions) propagates and
|
|
254
|
+
// watchSessions' poll `catch` simply skips the tick with `prev` intact — no fabricated removals.
|
|
255
|
+
async function listWorktrees(): Promise<{ path: string; branch: string | null }[]> {
|
|
256
|
+
const r = await gitTry(['-C', mainRoot(), 'worktree', 'list', '--porcelain'])
|
|
257
|
+
if (!r.ok) throw new Error(`git worktree list failed: ${r.stderr.trim() || 'unknown error'}`)
|
|
258
|
+
const list: { path: string; branch: string | null }[] = []
|
|
259
|
+
let cur: { path: string; branch: string | null } | null = null
|
|
260
|
+
for (const line of r.stdout.split('\n')) {
|
|
261
|
+
if (line.startsWith('worktree ')) { cur = { path: line.slice(9), branch: null }; list.push(cur) }
|
|
262
|
+
else if (line.startsWith('branch ') && cur) cur.branch = line.slice(7).replace('refs/heads/', '')
|
|
263
|
+
}
|
|
264
|
+
if (!list.length) throw new Error('git worktree list returned no worktrees (enumeration failed; the main worktree is always present)')
|
|
265
|
+
return list
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// @@@ reconcile - the shown status. awaiting → the proposal's label (review/done/close-pending),
|
|
269
|
+
// shown regardless of liveness. active/idle → their LIVENESS: offline if no tmux for the recorded id OR
|
|
270
|
+
// claude's rendezvous socket is gone (claude exited), else idle if the idle_prompt hook has fired since
|
|
271
|
+
// the last tool use, else working.
|
|
272
|
+
|
|
273
|
+
// @@@ liveTmux - which of OUR tmux sessions exist, in ONE tmux call. reconcile used to spawn two tmux per
|
|
274
|
+
// session (has-session + display-message), so listing N sessions was 2N spawns — the dominant /api/sessions
|
|
275
|
+
// cost under multi-agent load. `tmux list-sessions` returns every session on our socket at once; a session
|
|
276
|
+
// present in this set has a live tmux window (session_name = the id we created it with). tmux server down /
|
|
277
|
+
// no sessions → empty set → everything reconciles to offline, which is correct. We deliberately do NOT read
|
|
278
|
+
// `pane_current_command` any more: workers launch through the `reclaude` wrapper, which runs claude as a
|
|
279
|
+
// CHILD rather than exec'ing it, so the pane's foreground command is the wrapper/shell even while claude is
|
|
280
|
+
// very much alive — the pane command is NOT a liveness signal. claude liveness is its rendezvous socket
|
|
281
|
+
// (see reconcile). The per-session alive() above stays for the single-session ops (capture / rawKey).
|
|
282
|
+
async function liveTmux(): Promise<Set<string>> {
|
|
283
|
+
const s = new Set<string>()
|
|
284
|
+
let out = ''
|
|
285
|
+
try { out = await tmux(['list-sessions', '-F', '#{session_name}']) } catch { return s }
|
|
286
|
+
for (const line of out.split('\n')) { const name = line.trim(); if (name) s.add(name) }
|
|
287
|
+
return s
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// @@@ paneTitles - every session pane's RAW tmux title, free from tmux. The worker launches one pane per
|
|
291
|
+
// session, named with the session id, so ONE `list-panes -a` maps id → its raw `#{pane_title}`. Same shape
|
|
292
|
+
// and cost as liveTmux (one tmux call for the whole list); failure → empty map, so a tmux hiccup just drops
|
|
293
|
+
// the subtitle for a tick, never the session. The raw title is NOT yet a headline — what a pane title MEANS
|
|
294
|
+
// is harness-specific (claude: a self-authored task summary; codex: a spinner + the cwd folder name), so the
|
|
295
|
+
// id→harness gating + glyph parse happens per session in paneActivity, not here.
|
|
296
|
+
async function paneTitles(): Promise<Map<string, string>> {
|
|
297
|
+
const m = new Map<string, string>()
|
|
298
|
+
let out = ''
|
|
299
|
+
try { out = await tmux(['list-panes', '-a', '-F', '#{session_name}\t#{pane_title}']) } catch { return m }
|
|
300
|
+
for (const line of out.split('\n')) {
|
|
301
|
+
const tab = line.indexOf('\t'); if (tab < 0) continue
|
|
302
|
+
const id = line.slice(0, tab), title = line.slice(tab + 1)
|
|
303
|
+
if (id && title) m.set(id, title)
|
|
304
|
+
}
|
|
305
|
+
return m
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// @@@ paneActivity - the harness-aware live self-summary: the SINGLE place a raw pane title becomes (or does
|
|
309
|
+
// NOT become) a session's headline activity. The board headline derives from the pane title ONLY for a
|
|
310
|
+
// harness whose pane title is its own task self-summary (`paneTitleIsSelfSummary`, an adapter capability —
|
|
311
|
+
// [[harness-adapter]]). claude qualifies (it writes its task summary into the OSC title), so we parse it with
|
|
312
|
+
// selfSummary (glyph-gated). codex does NOT — its pane title is a spinner glyph + the cwd FOLDER name, so
|
|
313
|
+
// returning it would headline the worktree folder, not the task; we refuse it (→ null) and sessionHeadline
|
|
314
|
+
// falls through to promptPreview (the launch prompt). The ONLY harness branch is the capability read here —
|
|
315
|
+
// no `if (codex)`, no glyph special-case; selfSummary stays the pure claude-title parser.
|
|
316
|
+
export function paneActivity(harness: Harness, paneTitle: string | null | undefined): string | null {
|
|
317
|
+
if (paneTitle == null || !harness.paneTitleIsSelfSummary) return null
|
|
318
|
+
return selfSummary(paneTitle)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// @@@ selfSummary - the agent's OWN live one-line description, parsed from its tmux pane title — the SINGLE
|
|
322
|
+
// place the "is this the agent speaking?" rule lives, exported so it is unit-auditable. Claude Code sets that
|
|
323
|
+
// title via an OSC escape and ALWAYS leads it with a status glyph: ✳ (and its ✶✻✽✢ blink frames) when idle, a
|
|
324
|
+
// braille spinner frame (U+2800–U+28FF) while working. That leading glyph is the only reliable proof the
|
|
325
|
+
// title is the agent and not tmux's default — which, from pane birth until the first turn, is the HOST NAME
|
|
326
|
+
// (e.g. `ser581555022561`) or a bare `Claude Code` splash. So the glyph is REQUIRED: no leading glyph → null,
|
|
327
|
+
// and the caller keeps showing the launch-prompt placeholder instead of flickering through the host name and
|
|
328
|
+
// splash. The leading glyph run (with the spaces/`·` between and after) is stripped — the dashboard draws its
|
|
329
|
+
// own status dot, a frozen spinner frame is just noise — leaving only the summary text (null if it is empty).
|
|
330
|
+
// ONE regex is the single source of the glyph rule: it gates (requires ≥1 glyph) and strips in one match.
|
|
331
|
+
export function selfSummary(paneTitle: string): string | null {
|
|
332
|
+
const m = /^[\s·]*(?:[✳✶✻✽✢⠀-⣿][\s·]*)+(.*)$/u.exec(paneTitle)
|
|
333
|
+
return m ? (m[1].trim() || null) : null
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// @@@ launchedAt - when we last started a tmux window for an id (set in launch()). claude needs ~15-20s
|
|
337
|
+
// after the window appears to recreate its rendezvous socket; in that window the socket is absent but the
|
|
338
|
+
// session is booting, NOT dead. reconcile consults this to report 'starting' (a distinct transient state)
|
|
339
|
+
// instead of 'offline' for BOOT_GRACE_MS after launch — so 'offline' only ever means genuinely dead. In-
|
|
340
|
+
// memory in the single server process (lost on restart, which is fine: a restart has nothing in flight).
|
|
341
|
+
const launchedAt = new Map<string, number>()
|
|
342
|
+
const BOOT_GRACE_MS = 25000 // > waitForReady's 15s timeout, covering the observed ~15-20s agent boot window
|
|
343
|
+
|
|
344
|
+
// @@@ liveness - the orthogonal axis ([[state]]): is the agent process up, for ANY session regardless of
|
|
345
|
+
// lifecycle, from a prebuilt tmux set (no per-call spawn — see liveTmux) + the rendezvous socket. offline
|
|
346
|
+
// iff the tmux window is gone OR claude's rendezvous socket is absent past the boot window. claude (via the
|
|
347
|
+
// reclaude wrapper) holds CLAUDE_BG_RENDEZVOUS_SOCK open the whole time it is alive, so the socket — NOT
|
|
348
|
+
// pane_current_command, which is the wrapper/shell while claude runs as its child — is the truth it is up.
|
|
349
|
+
// A just-launched agent whose socket hasn't appeared yet reads the transient 'starting' for the grace
|
|
350
|
+
// window; only past it (socket still gone) is it genuinely 'offline'.
|
|
351
|
+
function liveness(rec: SessRec, live: Set<string>): Liveness {
|
|
352
|
+
if (!rec.session) return 'offline'
|
|
353
|
+
// ask the ADAPTER ([[harness-adapter]]): claude = tmux up AND its rendezvous socket present; codex = tmux up,
|
|
354
|
+
// app-server up, AND native thread id captured. The 'starting' grace stays here (a launcher concern): a
|
|
355
|
+
// just-launched agent whose online-signal hasn't appeared yet reads 'starting' for the boot window, only past
|
|
356
|
+
// it 'offline'.
|
|
357
|
+
const h = harnessById(rec.harness || defaultHarness.id)
|
|
358
|
+
if (h.liveness(rec, live.has(rec.session), runtimeRoot()) === 'online') return 'online'
|
|
359
|
+
const at = launchedAt.get(rec.session)
|
|
360
|
+
return at && Date.now() - at < BOOT_GRACE_MS ? 'starting' : 'offline'
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// reconcile the compact DisplayStatus — a DERIVED label composing lifecycle + liveness for one-glyph
|
|
364
|
+
// surfaces ([[state]]), never a third source of truth. Lifecycle wins the label except where liveness must
|
|
365
|
+
// 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 idle
|
|
367
|
+
// → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
|
|
368
|
+
// terminal-mount and the relaunch panel on; this label is for badges and `spex ls`.
|
|
369
|
+
function reconcile(rec: SessRec, live: Set<string>): DisplayStatus {
|
|
370
|
+
if (rec.status === 'awaiting') return PROPOSAL_STATUS[rec.proposal || 'nothing']
|
|
371
|
+
if (rec.status !== 'active' && rec.status !== 'idle') return rec.status // parked | error | asking | queued (no tmux yet)
|
|
372
|
+
const lv = liveness(rec, live)
|
|
373
|
+
if (lv !== 'online') return lv // 'offline' | 'starting'
|
|
374
|
+
return rec.status === 'idle' ? 'idle' : 'working'
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// resolve a session id to its record + worktree. Now a DIRECT store read (the record carries worktree_path),
|
|
378
|
+
// not a scan of every worktree reading its `.session` — O(1) and exact. null when the id has no governed-or-not
|
|
379
|
+
// record. Shape kept ({path, branch, rec}) so the many callers (rename/propose/reopen/merge/close/…) are unchanged.
|
|
380
|
+
async function findWorktree(id: string): Promise<{ path: string; branch: string | null; rec: SessRec } | null> {
|
|
381
|
+
const rec = readRecord(id)
|
|
382
|
+
if (!rec) return null
|
|
383
|
+
return { path: rec.worktreePath, branch: rec.branch, rec }
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, activity: string | null = null): Session {
|
|
387
|
+
const prompt = readPromptFile(rec.session) // the originating ask, captured at launch (store artifact; null for old sessions)
|
|
388
|
+
// activity is the LIVE pane title; it only means anything while the worker is genuinely up — a
|
|
389
|
+
// dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
|
|
390
|
+
const showActivity = lv === 'online'
|
|
391
|
+
return { id: rec.session, node: rec.node, title: rec.title, name: rec.name, branch: rec.branch, path: rec.worktreePath, harness: rec.harness, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, prompt, promptPreview: prompt ? promptPreview(prompt) : null, created: rec.createdAt, activity: showActivity ? activity : null, sortKey: rec.sortKey }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
|
|
395
|
+
// over the derived label (node/title/branch/id) on every surface. Persisted to the worktree's `.session`
|
|
396
|
+
// — the only writer of that file — so the name survives backend restarts and is read back like any other
|
|
397
|
+
// field. A blank name CLEARS the override, reverting the row to its derived label. Works for a session in
|
|
398
|
+
// any state (queued/live/offline) since it edits the on-disk record, not the live tmux. Unknown id → false
|
|
399
|
+
// (the route answers 404). The frontend's right-click rename is the sole caller today.
|
|
400
|
+
export async function renameSession(id: string, name: string): Promise<boolean> {
|
|
401
|
+
const wt = await findWorktree(id)
|
|
402
|
+
if (!wt) return false
|
|
403
|
+
writeRecord({ ...wt.rec, name: name.trim() || null })
|
|
404
|
+
return true
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// @@@ setSessionSort - set (or clear) a session's drag-reorder pseudo-time ([[session-reorder]]), parallel
|
|
408
|
+
// to renameSession: persisted to the session's global record so the manual order survives restarts and
|
|
409
|
+
// shows on every surface (all sort by `sortKey ?? created`). A null key CLEARS it, dropping the row back to
|
|
410
|
+
// its `created` slot. Works in any state since it edits the on-disk record. Unknown id → false (route 404s).
|
|
411
|
+
export async function setSessionSort(id: string, key: number | null): Promise<boolean> {
|
|
412
|
+
const wt = await findWorktree(id)
|
|
413
|
+
if (!wt) return false
|
|
414
|
+
writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
|
|
415
|
+
return true
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded.
|
|
419
|
+
export async function sessionPrompt(id: string): Promise<string | null> {
|
|
420
|
+
return readRecord(id) ? readPromptFile(id) : null
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// @@@ lastKnownSession - the last successfully-read Session row per session_id. The record's EXISTENCE in
|
|
424
|
+
// the store is definitive; a transient failure reading it (an ENOENT race, or a sibling read failing under a
|
|
425
|
+
// concurrent merge) must NOT drop the row from the board — that absence is exactly what watchSessions used to
|
|
426
|
+
// mis-read as a `closed · removed`. So a degraded read serves this last-known row instead of vanishing. Pruned
|
|
427
|
+
// each poll to only ids still present.
|
|
428
|
+
const lastKnownSession = new Map<string, Session>()
|
|
429
|
+
|
|
430
|
+
// @@@ listSessions - the board's session list, enumerated from the GLOBAL per-session store (replacing the
|
|
431
|
+
// old `git worktree list` scan). Every GOVERNED record this project owns becomes a row, status reconciled;
|
|
432
|
+
// non-governed (user-self-launched) records are excluded — board state is a managed-session concern ([[state]]).
|
|
433
|
+
// Offline and awaiting ones still appear (their record persists), so a session is never lost from view.
|
|
434
|
+
export async function listSessions(): Promise<Session[]> {
|
|
435
|
+
// ONE store enumeration + ONE tmux liveness snapshot + ONE pane-title snapshot for the whole list (all
|
|
436
|
+
// independent), then every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
|
|
437
|
+
const [ids, live, titles] = await Promise.all([
|
|
438
|
+
Promise.resolve(listSessionIds()), liveTmux(), paneTitles(),
|
|
439
|
+
])
|
|
440
|
+
const rows = ids.map((id) => guardSession(id, () => {
|
|
441
|
+
const rec = readRecord(id)
|
|
442
|
+
if (!rec || !rec.governed) { lastKnownSession.delete(id); return null } // no record, or a self-launched (non-board) one
|
|
443
|
+
// the pane title → headline activity, gated by THIS session's harness ([[harness-adapter]]): claude's title
|
|
444
|
+
// is its task self-summary (used); codex's is the cwd folder name (refused → headline falls to the prompt).
|
|
445
|
+
const activity = paneActivity(harnessById(rec.harness || defaultHarness.id), titles.get(id))
|
|
446
|
+
const s = toSession(rec, reconcile(rec, live), liveness(rec, live), activity)
|
|
447
|
+
lastKnownSession.set(id, s)
|
|
448
|
+
return s
|
|
449
|
+
}, () => {
|
|
450
|
+
// DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
|
|
451
|
+
// session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
|
|
452
|
+
// show yet, it reappears next poll — and since it was never in watchSessions' `prev`, no false closed.)
|
|
453
|
+
return lastKnownSession.get(id) ?? null
|
|
454
|
+
}))
|
|
455
|
+
// prune last-known entries for ids that no longer appear at all (genuinely removed), keeping it bounded.
|
|
456
|
+
const liveIds = new Set(ids)
|
|
457
|
+
for (const k of [...lastKnownSession.keys()]) if (!liveIds.has(k)) lastKnownSession.delete(k)
|
|
458
|
+
// @@@ creation order - order by birth (oldest first): each session keeps its slot for life and a new one
|
|
459
|
+
// simply appends — a stable spatial map across every surface (dashboard window, session tabs, `spex ls`).
|
|
460
|
+
// `created` is the record's stored createdAt (set once at launch). A manual drag ([[session-reorder]])
|
|
461
|
+
// overrides one row's slot via a pseudo-time `sortKey`, so sort by `sortKey ?? created`; id breaks ties so
|
|
462
|
+
// same-instant births (or sort-keys) stay deterministic.
|
|
463
|
+
return rows.filter((s): s is Session => s != null).sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
// a per-session read guard mirroring resilience.guardWorktree but keyed on the store record (not a worktree
|
|
467
|
+
// path): run `primary`; if it throws AND the record dir still exists, the failure is transient → serve the
|
|
468
|
+
// `degraded` fallback; if the dir is gone (a genuine close), return null (omit). No async git, so it's sync.
|
|
469
|
+
function guardSession(id: string, primary: () => Session | null, degraded: () => Session | null): Session | null {
|
|
470
|
+
try { return primary() }
|
|
471
|
+
catch { return existsSync(sessionStoreDir(id)) ? degraded() : null }
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// @@@ session graph = LIVE monitors, not a stored relationship. An edge A→B means "agent A is RIGHT NOW
|
|
475
|
+
// running `spex watch B` (the Monitor tool) over B" — derived from live watch registrations, never a
|
|
476
|
+
// persisted subscription. When a `spex watch` process starts it registers here and heartbeats; the edge
|
|
477
|
+
// exists ONLY while that watch runs (deregistered on exit, dropped on a missed heartbeat). Single owner:
|
|
478
|
+
// this in-memory map in the SERVER process — the watch process (a separate `spex watch`) talks to it over
|
|
479
|
+
// HTTP (POST /api/sessions/graph/watch + …/unwatch). No datastore, no file: a backend restart starts
|
|
480
|
+
// empty and live watches re-register on their next heartbeat. Kept isolated from the board assembler.
|
|
481
|
+
// an edge is either a LIVE monitor arrow (A→B = A watches B, directed) or a recorded comms link (A↔B =
|
|
482
|
+
// they have exchanged `count` direct messages, undirected). The dashboard renders the two kinds apart.
|
|
483
|
+
export type Edge = { from: string; to: string; kind: 'monitor' | 'comms'; count?: number }
|
|
484
|
+
|
|
485
|
+
// @@@ comms log - direct agent talk ([[comms-edge]]), recorded per-worktree. `spex session send` goes
|
|
486
|
+
// THROUGH the backend (sendKeys); on a delivered message that carries a sender, the backend appends one
|
|
487
|
+
// {peer, ts} line to the RECIPIENT's comms log — each message counted exactly once, on the side the backend
|
|
488
|
+
// already resolved. Persisted (survives a backend restart, unlike the in-memory monitor registrations) and
|
|
489
|
+
// untracked, in the session's GLOBAL store dir (`comms.ndjson`, keyed by session_id) — it dies with the
|
|
490
|
+
// session record, matching a graph of LIVE sessions. No sender → not logged. Best-effort: a recording failure
|
|
491
|
+
// must NEVER fail the delivered message.
|
|
492
|
+
function commsLog(id: string): string { return sessionArtifactPath(id, 'comms.ndjson') }
|
|
493
|
+
async function recordComms(toId: string, fromId: string): Promise<void> {
|
|
494
|
+
if (!fromId || fromId === toId) return
|
|
495
|
+
try {
|
|
496
|
+
if (!readRecord(toId)) return
|
|
497
|
+
appendFileSync(join(storeDir(toId), 'comms.ndjson'), JSON.stringify({ peer: fromId, ts: new Date().toISOString() }) + '\n')
|
|
498
|
+
} catch { /* a recording failure must not fail the delivered send */ }
|
|
499
|
+
}
|
|
500
|
+
// the peers this session has exchanged messages with — one entry per message, newest appended last.
|
|
501
|
+
function readComms(id: string): string[] {
|
|
502
|
+
try {
|
|
503
|
+
const path = commsLog(id)
|
|
504
|
+
if (!existsSync(path)) return []
|
|
505
|
+
return readFileSync(path, 'utf8').split('\n').filter(Boolean)
|
|
506
|
+
.map((l) => { try { return String(JSON.parse(l).peer || '') } catch { return '' } }).filter(Boolean)
|
|
507
|
+
} catch { return [] }
|
|
508
|
+
}
|
|
509
|
+
// keyed by an opaque per-watch token (one per `spex watch` process), so a single agent may run several
|
|
510
|
+
// monitors without them clobbering each other. `selectors` is what the watch targets (resolved LIVE at
|
|
511
|
+
// read time, not frozen here); empty / @all = a GLOBAL watcher. `expires` is the heartbeat backstop.
|
|
512
|
+
type WatchReg = { watcher: string; selectors: string[]; expires: number }
|
|
513
|
+
const watches = new Map<string, WatchReg>()
|
|
514
|
+
const DEFAULT_WATCH_TTL_MS = 15000
|
|
515
|
+
// register OR heartbeat a live monitor. watcher = the watching agent's OWN session id; ttlMs = how long
|
|
516
|
+
// this stays live without another beat. Returns false on a bad pair (the route answers 400).
|
|
517
|
+
export function registerWatch(token: string, watcher: string, selectors: string[], ttlMs = DEFAULT_WATCH_TTL_MS): boolean {
|
|
518
|
+
if (!token || !watcher) return false
|
|
519
|
+
watches.set(token, { watcher, selectors: selectors.filter(Boolean), expires: Date.now() + Math.max(1000, ttlMs) })
|
|
520
|
+
return true
|
|
521
|
+
}
|
|
522
|
+
// deregister a watch (its `spex watch` exited); false if the token wasn't registered.
|
|
523
|
+
export function deregisterWatch(token: string): boolean { return watches.delete(token) }
|
|
524
|
+
// the still-live registrations, pruning any whose heartbeat lapsed — the backstop for a watch that died
|
|
525
|
+
// without a clean unwatch (SIGKILL, a dropped connection, a backend that was down at exit time).
|
|
526
|
+
function liveWatches(): WatchReg[] {
|
|
527
|
+
const now = Date.now()
|
|
528
|
+
const out: WatchReg[] = []
|
|
529
|
+
for (const [token, reg] of watches) {
|
|
530
|
+
if (reg.expires <= now) watches.delete(token)
|
|
531
|
+
else out.push(reg)
|
|
532
|
+
}
|
|
533
|
+
return out
|
|
534
|
+
}
|
|
535
|
+
// the graph: live sessions as nodes; edges DERIVED from live monitor registrations. Edge A→B = watcher A
|
|
536
|
+
// is currently watching B. Selectors are resolved LIVE here via selectSessions (the same matcher `spex
|
|
537
|
+
// ls/watch` use), so a global (@all/empty) watcher links to every CURRENT session — incl. ones launched
|
|
538
|
+
// after the watch started — and a node/branch selector picks up future matches too. Self-edges and edges
|
|
539
|
+
// touching a non-live session are dropped; duplicate A→B (two watches over the same pair) collapse to one.
|
|
540
|
+
export async function sessionGraph(): Promise<{ nodes: Session[]; edges: Edge[] }> {
|
|
541
|
+
const nodes = await listSessions()
|
|
542
|
+
const live = new Set(nodes.map((s) => s.id))
|
|
543
|
+
const edges: Edge[] = []
|
|
544
|
+
const seen = new Set<string>()
|
|
545
|
+
for (const reg of liveWatches()) {
|
|
546
|
+
if (!live.has(reg.watcher)) continue // the watching agent itself is gone
|
|
547
|
+
for (const t of selectSessions(nodes, reg.selectors)) {
|
|
548
|
+
if (t.id === reg.watcher) continue
|
|
549
|
+
const key = `${reg.watcher} ${t.id}`
|
|
550
|
+
if (seen.has(key)) continue
|
|
551
|
+
seen.add(key)
|
|
552
|
+
edges.push({ from: reg.watcher, to: t.id, kind: 'monitor' })
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// comms edges: undirected direct-talk, one per pair, carrying the message count — read from each live
|
|
556
|
+
// session's per-worktree log and aggregated by sorted pair so A→B and B→A fold into one A↔B count. An
|
|
557
|
+
// edge to a non-live session is dropped, like the monitor edges.
|
|
558
|
+
const commsCount = new Map<string, number>()
|
|
559
|
+
for (const n of nodes) {
|
|
560
|
+
for (const peer of readComms(n.id)) {
|
|
561
|
+
if (peer === n.id || !live.has(peer)) continue
|
|
562
|
+
const key = n.id < peer ? `${n.id}\t${peer}` : `${peer}\t${n.id}`
|
|
563
|
+
commsCount.set(key, (commsCount.get(key) ?? 0) + 1)
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
for (const [key, count] of commsCount) {
|
|
567
|
+
const [from, to] = key.split('\t')
|
|
568
|
+
edges.push({ from, to, kind: 'comms', count })
|
|
569
|
+
}
|
|
570
|
+
return { nodes, edges }
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// @@@ watch registration (CLIENT side) - a `spex watch` process is separate from the server, so it
|
|
574
|
+
// REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
|
|
575
|
+
// deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
|
|
576
|
+
// streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
|
|
577
|
+
export const apiBase = () => process.env.SPEXCODE_API_URL || `http://127.0.0.1:${process.env.PORT || 8787}`
|
|
578
|
+
// the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
|
|
579
|
+
// Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
|
|
580
|
+
// re-exported here so callers keep one name. Used by `spex watch` + the agent-typed `spex session …`
|
|
581
|
+
// declarations; the hooks instead pass `--session <id>` from the payload, so they never depend on this.
|
|
582
|
+
export const ownSessionId = envSessionId
|
|
583
|
+
|
|
584
|
+
// @@@ withSenderHint - bidirectional agent messaging. `spex session send` delivers a prompt to the
|
|
585
|
+
// recipient; this stamps WHO sent it and HOW to reply as a one-line insert appended to the delivered
|
|
586
|
+
// message, so the recipient agent CAN reply (or ignore) and the reply rides the SAME send back into the
|
|
587
|
+
// sender's prompt — a reply channel, no workflow enforcement, just a prompt insert. The sender is the
|
|
588
|
+
// SENDING agent's OWN session (id from [[dispatch]]'s send-command process via ownSessionId, `label` its
|
|
589
|
+
// board HEADLINE — sessionHeadline, the same title the recipient reads on the board); its FULL id is stamped
|
|
590
|
+
// so the reply addresses exactly one session, never a prefix. A human running `send` from a plain shell has
|
|
591
|
+
// no session id (sender=null) → the bare message, no hint, no loop.
|
|
592
|
+
// @@@ delimited as a SESSION TITLE - the headline is wrapped `session "<headline>" (<id>)` so the recipient
|
|
593
|
+
// reads it AS a session title, not as prose bleeding into the message (an un-delimited prompt-derived title
|
|
594
|
+
// was unrecognisable as a name). A bare-id label (no better name in the chain) needs no quotes.
|
|
595
|
+
export type MsgSender = { id: string; label: string | null }
|
|
596
|
+
export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
597
|
+
if (!sender) return text
|
|
598
|
+
const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
|
|
599
|
+
return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
|
|
600
|
+
}
|
|
601
|
+
async function postJSON(path: string, body: unknown): Promise<void> {
|
|
602
|
+
try {
|
|
603
|
+
await fetch(`${apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
604
|
+
} catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
|
|
605
|
+
}
|
|
606
|
+
export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
|
|
607
|
+
postJSON('/api/sessions/graph/watch', { token, watcher, selectors, ttlMs })
|
|
608
|
+
export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/sessions/graph/unwatch', { token })
|
|
609
|
+
|
|
610
|
+
// @@@ isBackendDown - a `client.ts` BackendError surfacing in the watch poll loop (whose session
|
|
611
|
+
// `source` is the HTTP backend client). Matched by NAME, not `instanceof`, so sessions.ts never imports
|
|
612
|
+
// client.ts at runtime (client.ts imports apiBase from here — a runtime import back would be a cycle). A
|
|
613
|
+
// backend-down poll must NOT be swallowed as a transient git/tmux hiccup: watch warns ONCE and keeps
|
|
614
|
+
// streaming rather than emitting false `closed` events for every session.
|
|
615
|
+
export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.name === 'BackendError'
|
|
616
|
+
|
|
617
|
+
const slugify = (s: string | null) => (s || 'session').replace(/[^a-zA-Z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || 'session'
|
|
618
|
+
|
|
619
|
+
// @@@ node + title from the prompt - the spec node a session works on is whatever it @-mentions, NOT a UI
|
|
620
|
+
// "focused node": the dashboard prefills `@<focused> ` as a deletable convenience, so the node the user
|
|
621
|
+
// actually left in the prompt (changed it, or deleted it for a node-agnostic prompt) is the truth. We read
|
|
622
|
+
// the FIRST `@<id>` that begins a word (same positional rule the dashboard's mention menu uses). When there
|
|
623
|
+
// is none, the session is node-agnostic and we label it by the first few words of the prompt instead.
|
|
624
|
+
// 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 `@.config` captures nothing and never resolves to a node.
|
|
626
|
+
const MENTION = /(?:^|\s)@(\.?[A-Za-z0-9_-]+)/
|
|
627
|
+
const mentionedNode = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
|
|
628
|
+
function titleFromPrompt(prompt: string): string | null {
|
|
629
|
+
const first = (prompt || '').trim().split('\n')[0].trim()
|
|
630
|
+
const words = first.split(/\s+/).filter(Boolean).slice(0, 7).join(' ')
|
|
631
|
+
if (!words) return null
|
|
632
|
+
return words.length > 50 ? words.slice(0, 49).trimEnd() + '…' : words
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
// @@@ launchScript - the WHOLE launch invocation (rendezvous env prefix + harness command + the human prompt)
|
|
636
|
+
// is written to an ephemeral `launch.sh` in the session's GLOBAL store and
|
|
637
|
+
// run via `bash <file>`, NOT typed inline. Inline send-keys TRUNCATES past ~2KB (the launch-prompt-limit trap),
|
|
638
|
+
// and a long human prompt + spec pointer can exceed it; a file has no length limit
|
|
639
|
+
// and the only thing send-keys types is the short `bash <file>` line. It's the SAME command the inline path
|
|
640
|
+
// ran (env prefix exports the rendezvous vars to the claude child), just relocated to a file. Liveness no
|
|
641
|
+
// longer cares what the pane's foreground command is: claude runs as a child of bash (and, via the
|
|
642
|
+
// `reclaude` wrapper, a grandchild), so the pane command is the wrapper/shell — reconcile reads claude's
|
|
643
|
+
// rendezvous socket instead (present while claude is alive, gone once it exits). The file lives OUTSIDE the
|
|
644
|
+
// worktree (in the store, keyed by session_id), so it never pollutes the spec/code work.
|
|
645
|
+
function launchScript(id: string, tail: string, harness: Harness = HARNESS): string {
|
|
646
|
+
const file = join(storeDir(id), 'launch.sh')
|
|
647
|
+
// NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
|
|
648
|
+
// createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
|
|
649
|
+
// agent. The launch line is just the rendezvous env + the harness command + the session-id/spec-pointer/prompt tail.
|
|
650
|
+
writeFileSync(file, `${rvEnv(id, harness)} ${harness.launchCmd(id, runtimeRoot())} ${tail}\n`)
|
|
651
|
+
return file
|
|
652
|
+
}
|
|
653
|
+
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS): Promise<void> {
|
|
654
|
+
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)}`])
|
|
656
|
+
await tmux(['send-keys', '-t', id, 'Enter'])
|
|
657
|
+
launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
|
|
658
|
+
}
|
|
659
|
+
|
|
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
|
+
|
|
757
|
+
// @@@ concurrency cap + queue - keep at most maxActive() agents AUTONOMOUSLY PROGRESSING at once. A slot is
|
|
758
|
+
// COMPUTE pressure, so only an agent actually consuming it holds one: genuinely live (tmux window + rendezvous
|
|
759
|
+
// socket present) AND either churning (`working`) or paused-to-self-resume (`parked`). Every state that is
|
|
760
|
+
// WAITING ON THE HUMAN frees its slot — `idle` (stopped at its prompt), `asking` (asked a question), and the
|
|
761
|
+
// proposal states (review/done/close-pending) — exactly as `offline`/`queued` do. Those agents burn no
|
|
762
|
+
// compute, so they must NEVER block a fresh launch: the old rule counted them, so a pile of "waiting on you"
|
|
763
|
+
// sessions wedged the queue while the box sat near-idle (the reported blockage). Liveness is still checked
|
|
764
|
+
// directly (the socket truth reconcile uses), so an authored `parked` whose claude has since died does NOT
|
|
765
|
+
// pin a slot. The cap throttles concurrent COMPUTE; everything waiting-on-you waits cheap as a live pane.
|
|
766
|
+
const OCCUPIES_SLOT = new Set<DisplayStatus>(['working', 'parked', 'starting']) // starting's boot window is also held via `launching`
|
|
767
|
+
function isOccupying(s: Session, live: Set<string>): boolean {
|
|
768
|
+
if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
|
|
769
|
+
const rec = readRecord(s.id)
|
|
770
|
+
if (!rec) return false
|
|
771
|
+
return harnessById(rec.harness || defaultHarness.id).liveness(rec, live.has(rec.session), runtimeRoot()) === 'online' // and only while the agent is genuinely live (its adapter's channel)
|
|
772
|
+
}
|
|
773
|
+
// sessions we've JUST launched whose agent hasn't come online yet. During that boot window reconcile reads them
|
|
774
|
+
// `offline` (the adapter's online-signal not up yet) and isOccupying would miss them, so the drainer would
|
|
775
|
+
// over-launch and blow past the cap. We hold the slot here from launch until the agent is online (waitForReady)
|
|
776
|
+
// or it times out.
|
|
777
|
+
// In-memory in the single server process (the only drainer) — lost on restart, which is fine: a restart drains
|
|
778
|
+
// the durable `queued` worktrees fresh with nothing in flight.
|
|
779
|
+
const launching = new Set<string>()
|
|
780
|
+
let draining = false // re-entrancy guard: only one drain pass runs at a time (no double-launch)
|
|
781
|
+
|
|
782
|
+
// launch a prepared `queued` worktree: feed it its parked launch prompt, flip it to active. Returns false
|
|
783
|
+
// (leaving it queued, to be retried next drain) if the worktree/prompt is gone or the tmux launch threw.
|
|
784
|
+
async function startQueued(id: string): Promise<boolean> {
|
|
785
|
+
const wt = await findWorktree(id)
|
|
786
|
+
if (!wt) return false
|
|
787
|
+
const launchPrompt = readLaunchFile(id)
|
|
788
|
+
if (launchPrompt == null) return false // a queued session always has one; if it's gone, don't spin on it
|
|
789
|
+
launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
|
|
790
|
+
const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
|
|
791
|
+
try {
|
|
792
|
+
const sq = `'${launchPrompt.replace(/'/g, `'\\''`)}'`
|
|
793
|
+
await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h)
|
|
794
|
+
} catch {
|
|
795
|
+
launching.delete(id)
|
|
796
|
+
return false // launch failed → stays `queued`, retried on the next drain tick
|
|
797
|
+
}
|
|
798
|
+
writeRecord({ ...wt.rec, status: 'active', proposal: null })
|
|
799
|
+
removeLaunchFile(id) // consumed
|
|
800
|
+
// release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
|
|
801
|
+
// wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
|
|
802
|
+
void waitForReady(id, h).finally(() => launching.delete(id))
|
|
803
|
+
return true
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
// @@@ drainQueue - start as many `queued` sessions as there are free slots, oldest first. Idempotent and
|
|
807
|
+
// re-entrancy-guarded; safe to call on every slot-freeing event (newSession / close / propose) AND on a
|
|
808
|
+
// periodic tick (superviseQueue) — the periodic tick is what catches the AGENT-authored transitions
|
|
809
|
+
// (done/parked written by a hook SUBPROCESS, which can't reach this server's queue). Re-lists each iteration
|
|
810
|
+
// so a freshly launched session (held in `launching`) counts immediately and we never exceed the cap.
|
|
811
|
+
export async function drainQueue(): Promise<void> {
|
|
812
|
+
if (draining) return
|
|
813
|
+
draining = true
|
|
814
|
+
try {
|
|
815
|
+
const cap = maxActive() // read once per drain pass (spexcode.json → env → 6); won't shift mid-burst
|
|
816
|
+
for (;;) {
|
|
817
|
+
const [sessions, live] = await Promise.all([listSessions(), liveTmux()])
|
|
818
|
+
const occupied = sessions.reduce((n, s) => n + (launching.has(s.id) || isOccupying(s, live) ? 1 : 0), 0)
|
|
819
|
+
if (occupied >= cap) break
|
|
820
|
+
const next = sessions.find((s) => s.status === 'queued' && !launching.has(s.id))
|
|
821
|
+
if (!next) break
|
|
822
|
+
if (!(await startQueued(next.id))) break // launch failed → stop this pass; a later tick retries
|
|
823
|
+
}
|
|
824
|
+
} finally { draining = false }
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
|
|
828
|
+
// newSession/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
|
|
829
|
+
// going parked writes its .session from a hook subprocess the server never sees, and a crash just makes a
|
|
830
|
+
// socket vanish — so a timer is what turns those into freed slots. Cheap: one worktree+tmux snapshot per tick,
|
|
831
|
+
// and a no-op when nothing is queued. Idempotent (guarded), so a second call is harmless.
|
|
832
|
+
let supervisingQueue = false
|
|
833
|
+
export function superviseQueue(intervalMs = 3000): void {
|
|
834
|
+
if (supervisingQueue) return
|
|
835
|
+
supervisingQueue = true
|
|
836
|
+
const tick = async () => {
|
|
837
|
+
try { await drainQueue() } catch { /* transient git/tmux hiccup; next tick retries */ }
|
|
838
|
+
setTimeout(tick, intervalMs)
|
|
839
|
+
}
|
|
840
|
+
void tick()
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
// @@@ createSession (dispatch via backend) - `spex new` / `spex session new` must launch the worker in the
|
|
844
|
+
// BACKEND's process, not the caller's. The backend owns the launch env (notably SPEXCODE_CLAUDE_CMD, which
|
|
845
|
+
// reclaude strips from agent envs) AND the concurrency cap. An agent that runs `spex new` (e.g. a supervisor)
|
|
846
|
+
// has a stripped env, so an in-process launch would spawn workers under plain `claude` and 401 at boot. So
|
|
847
|
+
// the CLI POSTs to the running backend whenever one answers, making the backend the single owner of session
|
|
848
|
+
// launching. Only when NO backend is reachable do we fall back to launching in this process (with a stderr
|
|
849
|
+
// 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> {
|
|
851
|
+
let res: Response
|
|
852
|
+
try {
|
|
853
|
+
res = await fetch(`${apiBase()}/api/sessions`, {
|
|
854
|
+
method: 'POST',
|
|
855
|
+
headers: { 'content-type': 'application/json' },
|
|
856
|
+
body: JSON.stringify({ node, prompt, harness }),
|
|
857
|
+
})
|
|
858
|
+
} catch {
|
|
859
|
+
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
860
|
+
return newSession(node, prompt, harness)
|
|
861
|
+
}
|
|
862
|
+
if (!res.ok) throw new Error(`backend rejected session (${res.status}): ${await res.text().catch(() => '')}`)
|
|
863
|
+
return await res.json() as Session
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// @@@ newSession - durable worktree (branch node/<slug> off main) + .session label. The agent does NOT
|
|
867
|
+
// launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
|
|
868
|
+
// immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
|
|
869
|
+
// `spex session new`. A board directive (nn/dd) additionally mutates the worktree's spec tree up front and
|
|
870
|
+
// hands the agent a finish-the-op prompt.
|
|
871
|
+
export async function newSession(node: string | null, prompt: string, harness: string = defaultHarness.id): Promise<Session> {
|
|
872
|
+
const id = randomUUID()
|
|
873
|
+
const h = harnessById(harness) // throws on an unknown id — fail loud, never silently launch the wrong harness
|
|
874
|
+
const directive = parseDirective(prompt)
|
|
875
|
+
// node identity + label: a delete targets an existing node (link it); a new op has no id yet so it's
|
|
876
|
+
// labeled by the human's text; otherwise explicit --node wins, else the prompt's first @-mention.
|
|
877
|
+
const ref = directive?.kind === 'delete' ? directive.targetId
|
|
878
|
+
: directive?.kind === 'new' ? null
|
|
879
|
+
: (node || mentionedNode(prompt))
|
|
880
|
+
const title = ref ? null : titleFromPrompt(directive?.rest ?? prompt)
|
|
881
|
+
const slug = `${slugify(ref || title || (directive ? `${directive.kind}-node` : null))}-${id.slice(0, 4)}`
|
|
882
|
+
const branch = `node/${slug}`
|
|
883
|
+
const path = join(mainRoot(), '.worktrees', slug)
|
|
884
|
+
await gitA(['-C', mainRoot(), 'worktree', 'add', '-b', branch, path, mainBranch()])
|
|
885
|
+
// prepared but NOT launched: enters the queue as `queued`. drainQueue() below launches it at once when a
|
|
886
|
+
// slot is free, else it waits — durable as a global record (+ its worktree), so it survives a backend
|
|
887
|
+
// restart and is still findable. governed:true — this is a DASHBOARD/CLI-launched session, so it feeds the
|
|
888
|
+
// board and the lifecycle hooks act on it; worktreePath/branch/createdAt are stamped here (the record, not
|
|
889
|
+
// the worktree, is the board's enumeration source now).
|
|
890
|
+
const rec: SessRec = {
|
|
891
|
+
session: id, governed: true, worktreePath: path, branch,
|
|
892
|
+
node: ref || null, title, name: null, status: 'queued', proposal: null, merges: 0, note: null, sortKey: null, createdAt: Date.now(),
|
|
893
|
+
harness: h.id, harnessSessionId: null,
|
|
894
|
+
}
|
|
895
|
+
writeRecord(rec)
|
|
896
|
+
writePromptFile(id, prompt) // capture the ORIGINATING prompt (the human/manager's ask) as store metadata (best-effort)
|
|
897
|
+
// render the harness-discovered artifacts INTO the worktree (CLAUDE.md/AGENTS.md contract block, .claude/.codex
|
|
898
|
+
// shims, manifest to the global store) so the launched agent gets the contract + hooks the SAME way a
|
|
899
|
+
// self-launched one does — by auto-discovery, not CLI injection. This is why the launch line below carries no
|
|
900
|
+
// --append-system-prompt / --settings, and why we no longer hide CLAUDE.md: hiding it suppressed the agent's
|
|
901
|
+
// own memory load too. One delivery path for both launch modes ([[harness-delivery]]).
|
|
902
|
+
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
|
+
let launchPrompt = prompt
|
|
907
|
+
if (directive?.kind === 'new') {
|
|
908
|
+
const placeholderId = `untitled-${id.slice(0, 4)}`
|
|
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.
|
|
915
|
+
// Append ONE line pointing the agent at that node's spec.md as an ABSOLUTE path INSIDE its own worktree, so
|
|
916
|
+
// it reads the LIVE file (never a stale snapshot we'd inject). relPath already carries the .spec/ prefix and
|
|
917
|
+
// is identical in this freshly-branched worktree, so the absolute path is just join(worktree, relPath). Only
|
|
918
|
+
// a real node gets a pointer; an unknown id resolves to nothing and we fail quiet (no pointer appended).
|
|
919
|
+
const spec = (await loadSpecs()).find((n) => n.id === ref)
|
|
920
|
+
if (spec) launchPrompt = `${prompt}\n\nThe spec node \`${ref}\` is your ground truth — read its spec at ${join(path, spec.path)}.`
|
|
921
|
+
}
|
|
922
|
+
writeLaunchFile(id, launchPrompt) // park the exact launch prompt for the drainer (consumed at launch)
|
|
923
|
+
await drainQueue() // launch now if under the cap, else leave it queued for a free slot
|
|
924
|
+
const after = readRecord(id) ?? rec // 'active' if the drain launched it, else still 'queued'
|
|
925
|
+
// queued → no process yet (offline liveness); just-launched → its socket is still booting (starting).
|
|
926
|
+
const queued = after.status === 'queued'
|
|
927
|
+
return toSession(after, queued ? 'queued' : 'working', queued ? 'offline' : 'starting')
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
// @@@ waitForReady - after a launch/relaunch, the agent needs SEVERAL SECONDS to come up; launch() only TYPES
|
|
931
|
+
// the start line via send-keys and returns immediately, so the agent's online-signal does not exist yet on
|
|
932
|
+
// return. Poll the ADAPTER's liveness ([[harness-adapter]]) at a small interval up to a bounded timeout so the
|
|
933
|
+
// agent counts as "ready" only once it is genuinely online — claude: its rendezvous socket up; codex: its
|
|
934
|
+
// project app-server socket up AND native thread id captured — then a follow-on dispatch (merge / send) lands
|
|
935
|
+
// in a LIVE agent instead of racing the boot and failing loud on a session that is actually recovering.
|
|
936
|
+
// BOUNDED + fail-loud preserved: a
|
|
937
|
+
// genuinely dead/unrecoverable agent never goes online, so after the timeout we return and the caller's own
|
|
938
|
+
// deliver() fails loud exactly as before — this only closes the startup race, it adds no fallback.
|
|
939
|
+
const SOCKET_READY_TIMEOUT_MS = 15000
|
|
940
|
+
const SOCKET_POLL_MS = 200
|
|
941
|
+
async function waitForReady(id: string, harness: Harness, timeoutMs = SOCKET_READY_TIMEOUT_MS): Promise<boolean> {
|
|
942
|
+
const deadline = Date.now() + timeoutMs
|
|
943
|
+
for (;;) {
|
|
944
|
+
const rec = readRecord(id)
|
|
945
|
+
if (rec && harness.liveness(rec, await alive(id), runtimeRoot()) === 'online') return true
|
|
946
|
+
if (Date.now() >= deadline) return false
|
|
947
|
+
await new Promise((r) => setTimeout(r, SOCKET_POLL_MS))
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// @@@ reopen - "back to working": clear any proposal → active, then ONE relaunch path. The agent needs
|
|
952
|
+
// (re)starting iff it isn't running for this id — the SAME deterministic liveness the adapter computes
|
|
953
|
+
// ([[harness-adapter]]): claude offline = no tmux OR no rendezvous socket (claude exited, even though the
|
|
954
|
+
// wrapper/shell may still hold the pane); codex offline = no tmux, no project app-server, or no captured native
|
|
955
|
+
// thread id. When it IS offline we drop any stale pane and launch a fresh window through the adapter's resumeArg
|
|
956
|
+
// — claude `--resume <id>` (the SAME conversation), codex `resume <thread-id>` once captured, else a fresh TUI
|
|
957
|
+
// in the same worktree/record. Then we WAIT for the
|
|
958
|
+
// agent to come online (waitForReady) before returning, so a caller that dispatches immediately after reopen
|
|
959
|
+
// (e.g. mergeSession) addresses a LIVE agent rather than racing the boot. If it's still live we only cleared
|
|
960
|
+
// the proposal — no wait. Also serves the plain "relaunch" of an offline (already-active) one. Fail-loud is
|
|
961
|
+
// unchanged: if the agent never comes online, the later deliver() fails loud.
|
|
962
|
+
export async function reopen(id: string): Promise<boolean> {
|
|
963
|
+
const wt = await findWorktree(id)
|
|
964
|
+
if (!wt) return false
|
|
965
|
+
const h = harnessById(wt.rec.harness || defaultHarness.id)
|
|
966
|
+
writeRecord({ ...wt.rec, status: 'active', proposal: null })
|
|
967
|
+
if (h.liveness(wt.rec, await alive(id), runtimeRoot()) !== 'online') {
|
|
968
|
+
await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane if any (no-op when none)
|
|
969
|
+
await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h)
|
|
970
|
+
await waitForReady(id, h) // a relaunched agent is "ready" only once the adapter reads it online
|
|
971
|
+
}
|
|
972
|
+
return true
|
|
973
|
+
}
|
|
974
|
+
|
|
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
|
+
// @@@ agent-authored state - the agent (forced by gates at boundaries) writes its OWN state; it is the
|
|
984
|
+
// authority on what a stop MEANS (awaiting human vs parked on a background task). External hooks only know
|
|
985
|
+
// SOMETHING changed, not the transition, so they force a write, never infer. The session it writes is resolved
|
|
986
|
+
// by id: `sessionId` (the hooks pass `--session <id>` from the payload) wins, else ownSessionId() (the env var
|
|
987
|
+
// the agent's own `spex session …` carries). Unknown id / no record → false (the route/CLI reports it).
|
|
988
|
+
export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?: string; sessionId?: string } = {}): boolean {
|
|
989
|
+
const id = opts.sessionId || ownSessionId()
|
|
990
|
+
if (!id) return false
|
|
991
|
+
const rec = readRecord(id)
|
|
992
|
+
if (!rec) return false
|
|
993
|
+
writeRecord({
|
|
994
|
+
...rec, status,
|
|
995
|
+
proposal: status === 'awaiting' ? (opts.proposal ?? 'nothing') : null,
|
|
996
|
+
note: opts.note ?? null,
|
|
997
|
+
})
|
|
998
|
+
return true
|
|
999
|
+
}
|
|
1000
|
+
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string) => markState('awaiting', { proposal, sessionId })
|
|
1001
|
+
export const markError = (sessionId?: string) => markState('error', { sessionId })
|
|
1002
|
+
export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
|
|
1003
|
+
const id = sessionId || ownSessionId()
|
|
1004
|
+
if (!id || !harnessSessionId) return false
|
|
1005
|
+
const rec = readRecord(id)
|
|
1006
|
+
if (!rec) return false
|
|
1007
|
+
writeRecord({ ...rec, harnessSessionId })
|
|
1008
|
+
return true
|
|
1009
|
+
}
|
|
1010
|
+
// @@@ markIdle - the ONE INFERRED state, so (unlike the agent-authored writers above) it carries a strict
|
|
1011
|
+
// active-only guard: the Notification(idle_prompt) hook fires it when claude is waiting at its prompt, and it
|
|
1012
|
+
// may ONLY overwrite `active` → `idle`. A deliberate declaration (awaiting / asking / parked / error) must
|
|
1013
|
+
// survive — idle only fills the gap where the agent stopped WITHOUT declaring (e.g. an API error killed the
|
|
1014
|
+
// turn before the Stop gate). The mark-active hook flips idle → active on resume. Same id resolution as markState.
|
|
1015
|
+
export function markIdle(sessionId?: string): boolean {
|
|
1016
|
+
const id = sessionId || ownSessionId()
|
|
1017
|
+
if (!id) return false
|
|
1018
|
+
const rec = readRecord(id)
|
|
1019
|
+
if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
|
|
1020
|
+
writeRecord({ ...rec, status: 'idle' })
|
|
1021
|
+
return true
|
|
1022
|
+
}
|
|
1023
|
+
// @@@ asking has TWO writers, both deterministic (neither guarded active-only): (1) the mark-active
|
|
1024
|
+
// PreToolUse hook captures it the instant the agent invokes the AskUserQuestion tool (status=asking,
|
|
1025
|
+
// the question as the note) — a HARD signal that the agent is asking the human; (2) the agent declares it
|
|
1026
|
+
// itself via markState('asking', { note }) — `spex session ask`, e.g. at the Stop gate. Either
|
|
1027
|
+
// way the mark-active path clears it back to active on the next tool / prompt, same as any non-active state.
|
|
1028
|
+
|
|
1029
|
+
// @@@ mergeReadiness - the deterministic commit gate the Stop hook enforces before a session may declare
|
|
1030
|
+
// done / propose merge. The dogfood ritual lands every change as a COMMIT on the node branch first, so two
|
|
1031
|
+
// states block a declaration: (1) any uncommitted working-tree change, or (2) 0 commits ahead of main
|
|
1032
|
+
// (nothing committed to merge). Since the global-store refactor, SpexCode writes NO per-session files into
|
|
1033
|
+
// the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are gitignored
|
|
1034
|
+
// (the materialize shims/skills) or tracked-and-committed (the contract block in CLAUDE.md/AGENTS.md), so
|
|
1035
|
+
// neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
|
|
1036
|
+
// work, no runtime-file filtering needed.
|
|
1037
|
+
// Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
|
|
1038
|
+
// can't misdirect repo discovery to the cwd (the same trap git.ts documents). `main` resolves via the shared
|
|
1039
|
+
// refs, so `main..HEAD` works from any linked worktree regardless of where main is checked out.
|
|
1040
|
+
export function mergeReadiness(): { ready: boolean; reason?: string } {
|
|
1041
|
+
let dirty: string[] = []
|
|
1042
|
+
try {
|
|
1043
|
+
dirty = git(['status', '--porcelain', '--untracked-files=all']).split('\n').filter(Boolean).map(porcelainPath)
|
|
1044
|
+
} catch { /* git status failed — fall through to the ahead check, still a real guard */ }
|
|
1045
|
+
if (dirty.length) {
|
|
1046
|
+
const shown = dirty.slice(0, 8).join(', ') + (dirty.length > 8 ? ', …' : '')
|
|
1047
|
+
return { ready: false, reason: `uncommitted changes on your node branch (${shown}) — commit your spec+code first` }
|
|
1048
|
+
}
|
|
1049
|
+
let ahead = 0
|
|
1050
|
+
const base = mainBranch()
|
|
1051
|
+
try { ahead = Number(git(['rev-list', '--count', `${base}..HEAD`]).trim()) || 0 } catch { ahead = 0 }
|
|
1052
|
+
if (ahead === 0) return { ready: false, reason: `your node branch is 0 commits ahead of ${base} — nothing is committed to merge` }
|
|
1053
|
+
return { ready: true }
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
// the path a `git status --porcelain` line refers to: strip the `XY ` status, and for a rename keep the
|
|
1057
|
+
// NEW path (after ` -> `). Shared by the dirty-file counters (mergeReadiness above, reviewPayload below).
|
|
1058
|
+
function porcelainPath(line: string): string {
|
|
1059
|
+
let p = line.slice(3)
|
|
1060
|
+
const arrow = p.indexOf(' -> '); if (arrow >= 0) p = p.slice(arrow + 4)
|
|
1061
|
+
return p
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// @@@ MANAGER COCKPIT - the review payload (the cockpit's first verb; see the manager-cockpit spec node).
|
|
1065
|
+
// One server-side bundle that lets a manager (human or agent) decide whether to merge a session WITHOUT
|
|
1066
|
+
// 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/typecheck/lint gates, and the agent's standing
|
|
1068
|
+
// proposal. ahead/dirty/diff/conflicts are computed against the SESSION's worktree (per id); typecheck and
|
|
1069
|
+
// lint reflect the CLI package's OWN location (where this runs) — the spec-cli that's actually live. null
|
|
1070
|
+
// when no session has that id.
|
|
1071
|
+
export type ReviewGates = {
|
|
1072
|
+
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
|
+
lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
|
|
1075
|
+
}
|
|
1076
|
+
export type ReviewPayload = {
|
|
1077
|
+
id: string; node: string | null; branch: string | null
|
|
1078
|
+
ahead: number // commits the node branch is ahead of main
|
|
1079
|
+
dirtyNonRuntime: number // uncommitted files excluding SpexCode's own runtime files
|
|
1080
|
+
diff: ReviewDiffFile[] // the worker's real changes, anchored at the merge-base
|
|
1081
|
+
gates: ReviewGates
|
|
1082
|
+
proposal: { kind: Proposal | null; note: string | null } // the session's standing proposal + its note
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// @@@ typecheckPkg - `tsc --noEmit` on the CLI package at its OWN location (pkgRoot — never a hardcoded
|
|
1086
|
+
// path), using the tsc binary from that package's node_modules. errorCount counts `error TSxxxx` lines; ok
|
|
1087
|
+
// is the exit status. If tsc can't be spawned at all (no node_modules) it resolves ok:false / 0 errors — a
|
|
1088
|
+
// loud "couldn't typecheck" rather than a false green.
|
|
1089
|
+
function typecheckPkg(): Promise<{ ok: boolean; errorCount: number }> {
|
|
1090
|
+
const root = pkgRoot()
|
|
1091
|
+
const tsc = join(root, 'node_modules', '.bin', 'tsc')
|
|
1092
|
+
return new Promise((resolve) => {
|
|
1093
|
+
execFile(tsc, ['--noEmit'], { cwd: root, encoding: 'utf8', maxBuffer: 1 << 24 }, (err, stdout) => {
|
|
1094
|
+
const out = (stdout || '') + (err && (err as unknown as { stdout?: string }).stdout || '')
|
|
1095
|
+
resolve({ ok: !err, errorCount: (out.match(/error TS\d+/g) || []).length })
|
|
1096
|
+
})
|
|
1097
|
+
})
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// @@@ reviewPayload - assemble the cockpit review for one session. The five session-specific reads
|
|
1101
|
+
// (ahead / dirty / diff / conflict gate) plus the two location gates (typecheck / lint) are all
|
|
1102
|
+
// independent, so they run in parallel. lint is the existing spec-lint module run in-process (it reports
|
|
1103
|
+
// over this process's repo — the CLI package's own tree).
|
|
1104
|
+
export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
1105
|
+
const wt = await findWorktree(id)
|
|
1106
|
+
if (!wt) return null
|
|
1107
|
+
const { specLint } = await import('./lint.js')
|
|
1108
|
+
const base = mainBranch()
|
|
1109
|
+
const [aheadOut, statusOut, diff, conflictsWithMain, typecheck, findings] = await Promise.all([
|
|
1110
|
+
gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
|
|
1111
|
+
gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
|
|
1112
|
+
mergeBaseDiff(wt.path, base),
|
|
1113
|
+
mergeConflicts(wt.path, base),
|
|
1114
|
+
typecheckPkg(),
|
|
1115
|
+
specLint(),
|
|
1116
|
+
])
|
|
1117
|
+
// the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
|
|
1118
|
+
// path is genuine work — this is just the total uncommitted count.
|
|
1119
|
+
const dirtyNonRuntime = statusOut.split('\n').filter(Boolean).map(porcelainPath).length
|
|
1120
|
+
return {
|
|
1121
|
+
id, node: wt.rec.node, branch: wt.branch,
|
|
1122
|
+
ahead: Number(aheadOut.trim()) || 0,
|
|
1123
|
+
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
|
+
},
|
|
1131
|
+
proposal: { kind: wt.rec.proposal, note: wt.rec.note },
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// @@@ mergePrompt - the human's merge INTENT, handed to the session's OWN agent. Merge is a DISPATCH, not a
|
|
1136
|
+
// server git script: the agent knows the work, so IT runs the merge, resolves any conflicts, and VERIFIES the
|
|
1137
|
+
// outcome — the guarantee lives in that verification, never a server-side gate. This is also the ONE place the
|
|
1138
|
+
// merge STYLE is stated (no other mechanism carries it): a --no-ff merge commit `merge <branch>: <reason>`
|
|
1139
|
+
// into main. The agent runs git from the MAIN checkout (`-C <mainPath>`; its own cwd is the node worktree).
|
|
1140
|
+
// After a clean merge the branch is 0 ahead of main, so the agent proposes CLOSE — not merge (the commit gate
|
|
1141
|
+
// would block a merge proposal; propose-close is exempt) — and the human confirms the close.
|
|
1142
|
+
function mergePrompt(mainPath: string, branch: string, reason: string): string {
|
|
1143
|
+
const base = mainBranch()
|
|
1144
|
+
return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself.\n\n` +
|
|
1145
|
+
`1. Merge from the main checkout with a no-ff merge commit:\n git -C ${mainPath} merge --no-ff -m "merge ${branch}: ${reason}" ${branch}\n` +
|
|
1146
|
+
`2. If it conflicts, resolve the conflicts (you know the intent) and complete the merge commit. ` +
|
|
1147
|
+
`3. Verify it landed: \`${base}\`'s HEAD must now be the new merge commit and no merge may be left in progress — if anything went half-merged, run \`git -C ${mainPath} merge --abort\` and report it rather than leaving \`${base}\` mid-state. ` +
|
|
1148
|
+
`4. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
// @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
|
|
1152
|
+
// 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 → active, `--resume`s via reopen if tmux died —
|
|
1154
|
+
// which waits for the rendezvous socket, closing the just-relaunched-no-socket race) and dispatches mergePrompt
|
|
1155
|
+
// through sendKeys. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
|
|
1156
|
+
// the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
|
|
1157
|
+
// CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
|
|
1158
|
+
// re-checks gates, runs git, bumps `merges`, or closes the session — review shows the gates; the agent verifies.
|
|
1159
|
+
export async function mergeSession(id: string): Promise<{ dispatched: boolean; reason?: string }> {
|
|
1160
|
+
const wt = await findWorktree(id)
|
|
1161
|
+
if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
|
|
1162
|
+
const branch = wt.branch, main = mainRoot()
|
|
1163
|
+
if (!(await reopen(id))) return { dispatched: false, reason: 'could not reopen session' }
|
|
1164
|
+
const subject = (await gitA(['-C', main, 'log', '-1', '--format=%s', branch])).trim()
|
|
1165
|
+
const reason = subject.replace(/^spec:\s+/, '') || branch
|
|
1166
|
+
const r = await sendKeys(id, mergePrompt(main, branch, reason))
|
|
1167
|
+
if (!r.ok) return { dispatched: false, reason: r.error }
|
|
1168
|
+
return { dispatched: true }
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// @@@ stopAgentProcess - the shared teardown both exit and close begin with, so there is ONE kill path, not
|
|
1172
|
+
// two: kill the agent's tmux client, drop its boot-window stamp (else a just-launched id lingers in the grace
|
|
1173
|
+
// window reading `starting` instead of `offline`), and sweep its rendezvous socket. The socket lives in the OS
|
|
1174
|
+
// tmpdir (NOT the worktree), so worktree removal alone would leave it behind — closing many sessions over time
|
|
1175
|
+
// would accumulate stale `spexcode-rv-*.sock` files; we unlink it here (force = no error if claude/OS already
|
|
1176
|
+
// removed it). Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
|
|
1177
|
+
async function stopAgentProcess(id: string): Promise<void> {
|
|
1178
|
+
await tmuxOk(['kill-session', '-t', id])
|
|
1179
|
+
launchedAt.delete(id)
|
|
1180
|
+
try { rmSync(rvSock(id), { force: true }) } catch { /* best-effort sweep; tmpdir socket, claude/OS may already be gone */ }
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// @@@ exitSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
|
|
1184
|
+
// worktree + branch + transcript intact. The session stays on the board, now reading `offline` (no tmux window)
|
|
1185
|
+
// whatever its lifecycle, so the relaunch panel offers to --resume the SAME conversation (see reopen). This is
|
|
1186
|
+
// "step away, come back later"; closeSession is "discard this work". An offline session occupies no slot, so
|
|
1187
|
+
// the freed capacity drains a queued session next (drainQueue).
|
|
1188
|
+
export async function exitSession(id: string): Promise<boolean> {
|
|
1189
|
+
const wt = await findWorktree(id)
|
|
1190
|
+
await stopAgentProcess(id)
|
|
1191
|
+
void drainQueue() // an exit frees a slot — start the next queued session if any
|
|
1192
|
+
return !!wt
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// @@@ closeSession - the REMOVAL (human-confirmed): exit's soft stop PLUS removing the worktree + branch AND
|
|
1196
|
+
// the session's whole global-store record dir — the work is gone, not just stopped. Same stop primitive as
|
|
1197
|
+
// exitSession (no duplicate kill path), then the git worktree/branch teardown that exit deliberately skips,
|
|
1198
|
+
// then the store sweep (exit KEEPS the record so the session stays on the board offline; close discards it).
|
|
1199
|
+
export async function closeSession(id: string): Promise<boolean> {
|
|
1200
|
+
const wt = await findWorktree(id)
|
|
1201
|
+
await stopAgentProcess(id)
|
|
1202
|
+
if (wt) {
|
|
1203
|
+
await gitA(['-C', mainRoot(), 'worktree', 'remove', '--force', wt.path])
|
|
1204
|
+
if (wt.branch) await gitA(['-C', mainRoot(), 'branch', '-D', wt.branch])
|
|
1205
|
+
}
|
|
1206
|
+
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) } catch { /* best-effort sweep of the global record */ }
|
|
1207
|
+
void drainQueue() // a close frees a slot — start the next queued session if any
|
|
1208
|
+
return !!wt
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// @@@ captureSessionResult - the session's live pane as a one-shot snapshot (output), the server side of
|
|
1212
|
+
// `GET /api/sessions/:id/capture` that `spex capture` (a backend client) reads. A monitoring read MUST
|
|
1213
|
+
// distinguish "I failed to read" from "the pane is genuinely empty" — the old captureSession collapsed
|
|
1214
|
+
// unknown-id, offline, and capture-error all to `''`, indistinguishable from an empty pane (a blank screen
|
|
1215
|
+
// that exits 0 is worse than useless to a manager). So the result is DISCRIMINATED: an empty pane is a
|
|
1216
|
+
// legitimate `{ok:true, pane:''}`; the three failure modes carry distinct reasons the route maps to distinct
|
|
1217
|
+
// HTTP codes (unknown→404, offline→409, capture-failed→502). The known-vs-offline check only runs on the
|
|
1218
|
+
// cold/not-alive branch, so a live capture (the polled hot path) costs just the one capture-pane.
|
|
1219
|
+
export type CaptureResult = { ok: true; pane: string } | { ok: false; reason: 'unknown' | 'offline' | 'capture-failed' }
|
|
1220
|
+
export async function captureSessionResult(id: string): Promise<CaptureResult> {
|
|
1221
|
+
if (!(await alive(id))) {
|
|
1222
|
+
const known = (await listSessions()).some((s) => s.id === id)
|
|
1223
|
+
return { ok: false, reason: known ? 'offline' : 'unknown' }
|
|
1224
|
+
}
|
|
1225
|
+
try { return { ok: true, pane: await tmux(['capture-pane', '-e', '-p', '-t', id]) } }
|
|
1226
|
+
catch { return { ok: false, reason: 'capture-failed' } }
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// @@@ watch - the event source for Claude Code's Monitor tool (first-class managing-agent support).
|
|
1230
|
+
// Polls the session list and emits the COMPLETE session lifecycle so it's a true "subscribe to all
|
|
1231
|
+
// session changes" feed: a LAUNCH (first sighting of an id, even though it enters at 'working', which is
|
|
1232
|
+
// not actionable — emitted ONCE per id so a manager learns a new session started), each ACTIONABLE state
|
|
1233
|
+
// transition — review / done / close-pending (agent proposals), offline (process died), error — and the
|
|
1234
|
+
// removal. Per Monitor's "silence is not success" rule a vanished session pings too. Net feed:
|
|
1235
|
+
// launched → [actionable transitions] → closed. Each line names the suggested next action(s). Drop into Monitor:
|
|
1236
|
+
// Monitor({ command: 'spex watch', persistent: true, description: 'spex session state changes' })
|
|
1237
|
+
// @@@ presentation + selection - shared by `spex ls` (pretty), `spex watch` (events) and the API.
|
|
1238
|
+
export const STATUS_GLYPH: Record<DisplayStatus, string> = {
|
|
1239
|
+
working: '\u25cf', idle: '\u25cb', offline: '\u23fb', starting: '\u25d4', review: '\u25c6', done: '\u2713',
|
|
1240
|
+
'close-pending': '\u2715', parked: '\u29d6', error: '\u2717', asking: '\u2370', queued: '\u25cc',
|
|
1241
|
+
}
|
|
1242
|
+
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',
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
// @@@ session selectors - the ONE matcher every session command shares (see [[session-selectors]]). A
|
|
1247
|
+
// selector matches a session iff it is the session's full id, an id-PREFIX, its node, or its branch. This is
|
|
1248
|
+
// the single predicate; selectSessions (MANY) and resolveSession (ONE) both call it, so id-prefix/node/branch
|
|
1249
|
+
// resolution can never drift between "which sessions ls/watch/wait/graph show" and "which session
|
|
1250
|
+
// review/merge/send/close act on".
|
|
1251
|
+
export function matchesSelector(s: Session, q: string): boolean {
|
|
1252
|
+
return s.id === q || s.id.startsWith(q) || s.node === q || s.branch === q
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// no selectors (or '@all') = everything. Optional status filter on top. This IS the ls/watch subscription.
|
|
1256
|
+
export function selectSessions(all: Session[], selectors: string[], statuses?: string[]): Session[] {
|
|
1257
|
+
let out = all
|
|
1258
|
+
const sel = selectors.filter((x) => x && x !== '@all')
|
|
1259
|
+
if (sel.length) out = out.filter((s) => sel.some((q) => matchesSelector(s, q)))
|
|
1260
|
+
if (statuses && statuses.length) out = out.filter((s) => statuses.includes(s.status))
|
|
1261
|
+
return out
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// @@@ resolveSession - resolve ONE selector to ONE session against a board: the single-target counterpart of
|
|
1265
|
+
// selectSessions, for the control verbs (review/send/merge/close/reopen/capture/prompt). The backend matches
|
|
1266
|
+
// ids EXACTLY, so a verb resolves the selector here first and then calls with the FULL id — a node/branch/
|
|
1267
|
+
// prefix selector drives a verb just as it filters `ls`. The result is DISCRIMINATED so a caller can fail
|
|
1268
|
+
// precisely: an exact full-id hit wins outright (never reported ambiguous just for prefixing a longer id);
|
|
1269
|
+
// otherwise a lone match is `ok`, several is `ambiguous` (a prefix/node hitting many), none is `none`.
|
|
1270
|
+
export type Resolved = { ok: Session } | { ambiguous: Session[] } | { none: true }
|
|
1271
|
+
export function resolveSession(selector: string, sessions: Session[]): Resolved {
|
|
1272
|
+
const exact = sessions.find((s) => s.id === selector)
|
|
1273
|
+
if (exact) return { ok: exact }
|
|
1274
|
+
const hits = sessions.filter((s) => matchesSelector(s, selector))
|
|
1275
|
+
if (hits.length === 1) return { ok: hits[0] }
|
|
1276
|
+
return hits.length ? { ambiguous: hits } : { none: true }
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
const trunc = (s: string, n: number) => (s.length > n ? s.slice(0, n - 1) + '\u2026' : s)
|
|
1280
|
+
// short display label per status (only close-pending differs from the status name) \u2014 used by the legend.
|
|
1281
|
+
const SHORT: Partial<Record<DisplayStatus, string>> = { 'close-pending': 'close' }
|
|
1282
|
+
|
|
1283
|
+
// @@@ statusLegend - one-line glyph\u2192meaning key, BUILT from STATUS_GLYPH so it can never drift from
|
|
1284
|
+
// the glyphs the table actually prints. Shown under `spex ls` so the symbols are self-explanatory.
|
|
1285
|
+
export function statusLegend(color = true): string {
|
|
1286
|
+
const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
|
|
1287
|
+
const parts = (Object.keys(STATUS_GLYPH) as DisplayStatus[]).map(
|
|
1288
|
+
(k) => `${c(ANSI[k], STATUS_GLYPH[k])} ${SHORT[k] || k}`,
|
|
1289
|
+
)
|
|
1290
|
+
return c('90', ' key: ') + parts.join(' ')
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
// human-friendly aligned table: header + (glyph + colour + status + name + id + merges + note) rows +
|
|
1294
|
+
// a status legend, so the table tells the whole story (incl. each agent's note) at a glance.
|
|
1295
|
+
export function formatTable(sessions: Session[], color = true): string {
|
|
1296
|
+
const c = (code: string, t: string) => (color ? `\x1b[${code}m${t}\x1b[0m` : t)
|
|
1297
|
+
if (!sessions.length) return c('90', ' no living sessions')
|
|
1298
|
+
const header = c('90', ` ${'STATUS'.padEnd(13)} ${'NODE'.padEnd(22)} ${'ID'.padEnd(8)} ${'\u00d7'.padEnd(4)}${'PROMPT'.padEnd(42)}NOTE`)
|
|
1299
|
+
const rows = sessions.map((s) => {
|
|
1300
|
+
const g = STATUS_GLYPH[s.status] ?? '\u00b7'
|
|
1301
|
+
const code = ANSI[s.status] ?? '0'
|
|
1302
|
+
const name = sessionLabel(s).slice(0, 22).padEnd(22)
|
|
1303
|
+
const st = s.status.padEnd(13)
|
|
1304
|
+
const merges = (s.merges ? `\u00d7${s.merges}` : '').padEnd(4)
|
|
1305
|
+
const prompt = c('90', (s.promptPreview ? trunc(s.promptPreview, 40) : '').padEnd(42)) // what it was asked to do
|
|
1306
|
+
const note = s.note ? c('90', trunc(s.note, 50)) : ''
|
|
1307
|
+
return ` ${c(code, g)} ${c(code, st)} ${name} ${c('90', s.id.slice(0, 8))} ${merges}${prompt}${note}`
|
|
1308
|
+
})
|
|
1309
|
+
return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const WATCH_ACTIONABLE = new Set<DisplayStatus>(['review', 'done', 'close-pending', 'offline', 'error', 'asking'])
|
|
1313
|
+
const NEXT: Record<string, string> = {
|
|
1314
|
+
review: 'merge | reopen(back-to-working) | close',
|
|
1315
|
+
done: 'merge | reopen | close',
|
|
1316
|
+
'close-pending': 'close | reopen',
|
|
1317
|
+
offline: 'reopen (relaunch & resume)',
|
|
1318
|
+
error: 'reopen (relaunch & retry) | capture | close',
|
|
1319
|
+
asking: 'send "<msg>" | capture',
|
|
1320
|
+
idle: 'send "<msg>" | capture',
|
|
1321
|
+
queued: 'waiting for a free slot — starts automatically | close',
|
|
1322
|
+
}
|
|
1323
|
+
export function sessionEvent(s: Session): string {
|
|
1324
|
+
const note = s.note ? ` — note: ${s.note}` : ''
|
|
1325
|
+
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
1326
|
+
return `[spex] ${s.status} · ${sessionLabel(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`
|
|
1327
|
+
}
|
|
1328
|
+
// @@@ launchEvent - a session's FIRST sighting. A launch goes straight to 'working' (not actionable), so
|
|
1329
|
+
// without this the watch feed would be blind to new sessions starting. Emitted ONCE per id, regardless of
|
|
1330
|
+
// status, so `spex watch` is a complete lifecycle feed: launched → [actionable transitions] → closed.
|
|
1331
|
+
export function launchEvent(s: Session): string {
|
|
1332
|
+
const note = s.note ? ` — note: ${s.note}` : ''
|
|
1333
|
+
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
1334
|
+
return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
|
|
1335
|
+
}
|
|
1336
|
+
// @@@ source - the session board the poll reads. The CLI passes the BACKEND CLIENT (client.ts
|
|
1337
|
+
// clientListSessions), so `spex watch` streams whatever backend SPEXCODE_API_URL points at — including a
|
|
1338
|
+
// REMOTE machine's. It is REQUIRED (no local default): a forgotten source must be a compile error, never a
|
|
1339
|
+
// silent in-process read of the wrong (local) board — the exact false-green the 2-machine test guards.
|
|
1340
|
+
export type WatchOpts = { source: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number } }
|
|
1341
|
+
// @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex wait` runs on); a
|
|
1342
|
+
// plain watch (no `until`) streams forever and never resolves. The bound is what makes `wait` a one-shot
|
|
1343
|
+
// "block for a worker, then exit" that is GUARANTEED to return. The deadline is checked EVERY poll, before
|
|
1344
|
+
// EVERY sleep (and even when a poll throws), so a target stuck in ANY non-actionable state
|
|
1345
|
+
// (`working`/`parked`/`idle`/`queued`/`starting`) can never hang the caller — it exits at the deadline.
|
|
1346
|
+
// `reached` = the target hit an actionable status; the rest are the loud exits.
|
|
1347
|
+
export type WatchOutcome = { reached: DisplayStatus } | { timedOut: true } | { gone: true } | { backendDown: string }
|
|
1348
|
+
export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
|
|
1349
|
+
const { source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
|
|
1350
|
+
const tag = as ? `[${as}] ` : ''
|
|
1351
|
+
const prev = new Map<string, DisplayStatus>()
|
|
1352
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
1353
|
+
// the no-hang wall: a fixed deadline computed ONCE, checked unconditionally every iteration below.
|
|
1354
|
+
const deadline = until ? Date.now() + Math.max(1000, until.timeoutMs) : 0
|
|
1355
|
+
const isActionable = (st: DisplayStatus) => WATCH_ACTIONABLE.has(st) || (includeIdle && st === 'idle')
|
|
1356
|
+
let warnedDown = false
|
|
1357
|
+
for (;;) {
|
|
1358
|
+
try {
|
|
1359
|
+
// EXISTENCE is the selector-matched board across ALL statuses — listSessions now lists every worktree
|
|
1360
|
+
// that exists (a transient detail-read failure degrades a row, never drops it — see guardWorktree), so
|
|
1361
|
+
// membership here IS the worktree's existence. The `statuses` filter governs only which TRANSITIONS we
|
|
1362
|
+
// emit, never whether a session is present — using it for presence would read a status change out of the
|
|
1363
|
+
// filtered set as a (false) removal.
|
|
1364
|
+
const all = selectSessions(await source(), selectors)
|
|
1365
|
+
warnedDown = false // a successful poll re-arms the down-warning, so a recovered-then-redowned backend warns again
|
|
1366
|
+
const ids = new Set(all.map((s) => s.id))
|
|
1367
|
+
const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
|
|
1368
|
+
for (const s of all) {
|
|
1369
|
+
if (!prev.has(s.id)) emit(tag + launchEvent(s)) // FIRST sighting → launched, any status (incl. 'working'), once
|
|
1370
|
+
if (s.status === prev.get(s.id)) continue // only on transition, not every tick
|
|
1371
|
+
prev.set(s.id, s.status)
|
|
1372
|
+
if (passesStatus(s.status) && (WATCH_ACTIONABLE.has(s.status) || (includeIdle && s.status === 'idle'))) emit(tag + sessionEvent(s))
|
|
1373
|
+
}
|
|
1374
|
+
// @@@ closed = the worktree is GONE. Because listSessions lists every EXISTING worktree (a flaky detail
|
|
1375
|
+
// read degrades, never drops), an id absent from the board means its worktree directory was actually
|
|
1376
|
+
// removed: a DEFINITIVE fact, not a flaky absence. So removal needs no 2-poll debounce / existsSync
|
|
1377
|
+
// re-check; emit `closed` exactly once the moment the id leaves the list.
|
|
1378
|
+
for (const id of [...prev.keys()]) {
|
|
1379
|
+
if (ids.has(id)) continue
|
|
1380
|
+
prev.delete(id)
|
|
1381
|
+
emit(`${tag}[spex] closed \u00b7 removed [id ${id}]`)
|
|
1382
|
+
}
|
|
1383
|
+
// BOUNDED mode (`until`, what `spex wait` runs): return the moment a watched target is actionable; an empty selected set
|
|
1384
|
+
// means the target is gone (absent from the board), which it can never come back from. Both sit inside
|
|
1385
|
+
// the try, after the emit pass, so the caller still saw every transition before we hand control back.
|
|
1386
|
+
if (until) {
|
|
1387
|
+
const hit = all.find((s) => isActionable(s.status))
|
|
1388
|
+
if (hit) return { reached: hit.status }
|
|
1389
|
+
if (!all.length) return { gone: true }
|
|
1390
|
+
}
|
|
1391
|
+
} catch (e) {
|
|
1392
|
+
// a backend-down poll must NOT be swallowed as a transient hiccup AND must NOT emit a false `closed`
|
|
1393
|
+
// for every session: we skip the tick (prev is untouched → no phantom removals) and warn ONCE, loudly,
|
|
1394
|
+
// so a manager sees the stream is blind rather than reading silence as "all sessions fine".
|
|
1395
|
+
if (until && isBackendDown(e)) return { backendDown: (e as Error).message } // a bounded wait fails loud, never a false timeout
|
|
1396
|
+
if (isBackendDown(e) && !warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${(e as Error).message}; retrying every ${intervalMs / 1000}s…`) }
|
|
1397
|
+
}
|
|
1398
|
+
// the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep:
|
|
1399
|
+
// this is what guarantees `spex wait` can never hang on a worker stuck outside WATCH_ACTIONABLE.
|
|
1400
|
+
if (until && Date.now() >= deadline) return { timedOut: true }
|
|
1401
|
+
await sleep(intervalMs)
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1405
|
+
// @@@ sendKeys - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
|
|
1406
|
+
// ([[harness-adapter]]) — claude the rendezvous control socket (inject + submit + confirm accepted), codex
|
|
1407
|
+
// app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent fallback: a prompt that can't be
|
|
1408
|
+
// delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
|
|
1409
|
+
// ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
|
|
1410
|
+
// instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
|
|
1411
|
+
// harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
1412
|
+
export async function sendKeys(id: string, text: string, from?: string): Promise<DispatchResult> {
|
|
1413
|
+
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
1414
|
+
const rec = readRecord(id)
|
|
1415
|
+
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
1416
|
+
const h = harnessById(rec.harness || defaultHarness.id)
|
|
1417
|
+
const r = await h.deliver({ ...rec, runtimeDir: runtimeRoot() }, text)
|
|
1418
|
+
// record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
|
|
1419
|
+
// send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
|
|
1420
|
+
if (r.ok && from) void recordComms(id, from)
|
|
1421
|
+
return r
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
// @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
|
|
1425
|
+
// socket. Two channels, two jobs: the socket INJECTS a whole prompt (text + submit), which can drive the
|
|
1426
|
+
// agent's normal prompt but CANNOT navigate an interactive TUI select menu (e.g. `/model`'s list — ↑/↓ to
|
|
1427
|
+
// move, ←/→ to adjust, Enter to set, `s` for this-session, Esc to cancel). When the agent is in that
|
|
1428
|
+
// keystroke-navigation state its input box is replaced by the menu, so the dashboard's nav mode forwards
|
|
1429
|
+
// each key here in real time. send-keys is exactly right for single raw keys: named keys map to tmux's own
|
|
1430
|
+
// key names; a single printable char is sent literally (`-l`) so tmux doesn't reinterpret it. The dashboard
|
|
1431
|
+
// also drives the agent with MODIFIER COMBOS — a terminal's three modifiers carried as a `C-`/`M-`/`S-`
|
|
1432
|
+
// prefix on the token (e.g. `C-r`, `M-b`, `S-Tab`, `C-M-x`); those are passed to tmux UNescaped so it parses
|
|
1433
|
+
// the combo. One key per call, no socket and no Enter-synthesis — this IS the send-keys channel. False if
|
|
1434
|
+
// the tmux session is gone, or if the token isn't a known base after its prefixes (defends the send-keys arg).
|
|
1435
|
+
const TMUX_KEY: Record<string, string> = {
|
|
1436
|
+
Up: 'Up', Down: 'Down', Left: 'Left', Right: 'Right',
|
|
1437
|
+
Enter: 'Enter', Escape: 'Escape', Tab: 'Tab', Space: 'Space', Backspace: 'BSpace',
|
|
1438
|
+
Home: 'Home', End: 'End', Delete: 'DC',
|
|
1439
|
+
}
|
|
1440
|
+
// tmux honours an `S-` (shift) modifier ONLY on these named keys; on Enter/Space/BSpace it would send the
|
|
1441
|
+
// literal text "S-Enter" etc. (and shift is a no-op there anyway), so a stray S- is dropped. Shift+Tab is
|
|
1442
|
+
// the named exception: tmux spells it `BTab` (back-tab → ESC[Z, what Claude Code's mode-cycle reads).
|
|
1443
|
+
const SHIFTABLE = new Set(['Up', 'Down', 'Left', 'Right', 'Home', 'End', 'DC'])
|
|
1444
|
+
export async function rawKey(id: string, key: string): Promise<boolean> {
|
|
1445
|
+
if (!key || !(await alive(id))) return false
|
|
1446
|
+
// peel the optional C-/M-/S- modifier prefixes (each at most once, in any order) off the front; the
|
|
1447
|
+
// remainder is the BASE key. The frontend only ever sends {C-,M-,S-} prefixes + a named key or one char.
|
|
1448
|
+
let rest = key, prefix = ''
|
|
1449
|
+
const seen = new Set<string>()
|
|
1450
|
+
while (rest.length >= 2 && (rest[0] === 'C' || rest[0] === 'M' || rest[0] === 'S') && rest[1] === '-' && !seen.has(rest[0])) {
|
|
1451
|
+
seen.add(rest[0]); prefix += rest.slice(0, 2); rest = rest.slice(2)
|
|
1452
|
+
}
|
|
1453
|
+
const named = TMUX_KEY[rest]
|
|
1454
|
+
if (named) {
|
|
1455
|
+
const noShift = prefix.replace('S-', '') // C-/M- without the shift bit
|
|
1456
|
+
let token: string
|
|
1457
|
+
if (prefix.includes('S-') && named === 'Tab') token = noShift + 'BTab' // Shift+Tab → back-tab
|
|
1458
|
+
else if (prefix.includes('S-') && !SHIFTABLE.has(named)) token = noShift + named // tmux can't carry S- here
|
|
1459
|
+
else token = prefix + named
|
|
1460
|
+
await tmux(['send-keys', '-t', id, token]); return true
|
|
1461
|
+
}
|
|
1462
|
+
if ([...rest].length === 1) {
|
|
1463
|
+
// a single printable char: bare → literal (`-l`, so tmux never reinterprets it as a key name);
|
|
1464
|
+
// modified → hand tmux the `C-`/`M-`/`S-` combo to parse (e.g. `C-a`), which `-l` would defeat.
|
|
1465
|
+
if (prefix) { await tmux(['send-keys', '-t', id, prefix + rest]); return true }
|
|
1466
|
+
await tmux(['send-keys', '-t', id, '-l', '--', rest]); return true
|
|
1467
|
+
}
|
|
1468
|
+
return false
|
|
1469
|
+
}
|