spexcode 0.5.7 → 0.5.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/spec-cli/bin/spex.mjs +1 -13
- package/spec-cli/hooks/compat/mark-active-0.5.2-eef1.fixture +53 -0
- package/spec-cli/hooks/compat/mark-active-sed-v0.fixture +46 -0
- package/spec-cli/hooks/dispatch.sh +15 -19
- package/spec-cli/src/anchors.ts +199 -60
- package/spec-cli/src/attach.ts +7 -17
- package/spec-cli/src/cli.ts +144 -225
- package/spec-cli/src/client.ts +114 -132
- package/spec-cli/src/cockpit.ts +43 -0
- package/spec-cli/src/codex-runtime-generations.ts +556 -0
- package/spec-cli/src/commit-surgery.ts +0 -14
- package/spec-cli/src/contract-filter.ts +0 -14
- package/spec-cli/src/doctor.ts +0 -16
- package/spec-cli/src/gateway-auth.ts +0 -13
- package/spec-cli/src/gateway.ts +0 -22
- package/spec-cli/src/git.ts +151 -48
- package/spec-cli/src/graph.ts +28 -7
- package/spec-cli/src/graphCache.ts +253 -72
- package/spec-cli/src/graphDelta.ts +0 -13
- package/spec-cli/src/graphStream.ts +70 -37
- package/spec-cli/src/guide.ts +29 -6
- package/spec-cli/src/harness.ts +232 -248
- package/spec-cli/src/help.ts +27 -26
- package/spec-cli/src/host-resources.ts +11 -2
- package/spec-cli/src/index.ts +70 -95
- package/spec-cli/src/issues-cli.ts +276 -0
- package/spec-cli/src/issues.ts +15 -197
- package/spec-cli/src/layout.ts +78 -2
- package/spec-cli/src/lint.ts +42 -30
- package/spec-cli/src/localIssues.ts +22 -185
- package/spec-cli/src/loop-in.ts +78 -0
- package/spec-cli/src/materialize.ts +0 -42
- package/spec-cli/src/mentions.ts +49 -193
- package/spec-cli/src/opencode-headless.ts +7 -7
- package/spec-cli/src/opencode.ts +2 -5
- package/spec-cli/src/pi-headless.ts +5 -5
- package/spec-cli/src/pty-bridge.ts +4 -5
- package/spec-cli/src/reviewSnapshot.ts +1 -0
- package/spec-cli/src/reviews.ts +6 -1
- package/spec-cli/src/root-lru.ts +54 -0
- package/spec-cli/src/runtime-guard.ts +0 -9
- package/spec-cli/src/session-cursors.ts +97 -0
- package/spec-cli/src/session-follow.ts +182 -0
- package/spec-cli/src/session-timeline.ts +55 -127
- package/spec-cli/src/sessions.ts +207 -1022
- package/spec-cli/src/shim-runtime.ts +9 -16
- package/spec-cli/src/specs.ts +33 -10
- package/spec-cli/src/uninstall.ts +0 -9
- package/spec-cli/src/uploads.ts +240 -18
- package/spec-cli/src/worktree-sources.ts +0 -16
- package/spec-cli/templates/spec/project/.plugins/commands/supervisor/spec.md +1 -1
- package/spec-cli/templates/spec/project/.plugins/core/comment-altitude/spec.md +28 -0
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/mark-active.sh +43 -3
- package/spec-cli/templates/spec/project/.plugins/core/mark-active/spec.md +3 -1
- package/spec-cli/templates/spec/project/.plugins/core/stop-gate/stop-gate.sh +2 -2
- package/spec-cli/templates/spec/project/.plugins/skills/distill/spec.md +5 -0
- package/spec-cli/templates/spexcode.json +12 -0
- package/spec-dashboard/dist/assets/{App-u2P7KdSg.js → App-b8Nh0sgk.js} +2 -2
- package/spec-dashboard/dist/assets/{Dashboard-B8wp5_61.js → Dashboard-CvAjfRC2.js} +7 -7
- package/spec-dashboard/dist/assets/{EvalsPage-Bq1Tkb8y.js → EvalsPage-Bz-nMKoS.js} +1 -1
- package/spec-dashboard/dist/assets/IssuesPage-CAP64YWE.js +1 -0
- package/spec-dashboard/dist/assets/MobileApp-D9L1Va8Z.js +2 -0
- package/spec-dashboard/dist/assets/{Modal-bAkq9IIT.js → Modal-Drscez-d.js} +1 -1
- package/spec-dashboard/dist/assets/{PageScroll-px_rUZVJ.js → PageScroll-qW6uOJL8.js} +1 -1
- package/spec-dashboard/dist/assets/{ProjectsPage-8uGqYM12.js → ProjectsPage-CjybFBmR.js} +1 -1
- package/spec-dashboard/dist/assets/{SessionInterface-CswwbewF.js → SessionInterface-Dl9v0JFM.js} +13 -13
- package/spec-dashboard/dist/assets/SessionWindow-iOk0yHoU.js +1 -0
- package/spec-dashboard/dist/assets/{Settings-bpAbfnmS.js → Settings-BZ1lGRJs.js} +1 -1
- package/spec-dashboard/dist/assets/Thread-D_kcDnfd.js +13 -0
- package/spec-dashboard/dist/assets/{data-CQFbQEMH.js → data-Bwd3kAVL.js} +1 -1
- package/spec-dashboard/dist/assets/{index-CixSnz1H.css → index-DAbQBBK_.css} +1 -1
- package/spec-dashboard/dist/assets/{index-Di1ch5dd.js → index-paP-z_Vd.js} +6 -6
- package/spec-dashboard/dist/assets/{TimelineChat-Ckmb1Ez2.js → launch-B-bYdWmh.js} +25 -25
- package/spec-dashboard/dist/index.html +2 -2
- package/spec-dashboard/src/session.js +1 -28
- package/spec-eval/src/cache.ts +0 -14
- package/spec-eval/src/cli.ts +26 -5
- package/spec-eval/src/evaltab.ts +67 -108
- package/spec-eval/src/filing.ts +0 -18
- package/spec-eval/src/freshness.ts +78 -32
- package/spec-eval/src/humanok.ts +0 -15
- package/spec-eval/src/scenariofresh.ts +8 -80
- package/spec-eval/src/scenarios.ts +16 -67
- package/spec-eval/src/sessioneval.ts +157 -78
- package/spec-eval/src/sidecar.ts +1 -70
- package/spec-eval/src/timeline.ts +0 -23
- package/spec-forge/src/cache.ts +34 -12
- package/spec-forge/src/cli.ts +0 -6
- package/spec-forge/src/drivers/github.ts +1 -12
- package/spec-forge/src/drivers/gitlab.ts +4 -19
- package/spec-forge/src/drivers.ts +0 -18
- package/spec-forge/src/links.ts +0 -4
- package/spec-forge/src/needs-eval.ts +0 -1
- package/spec-forge/src/port.ts +0 -12
- package/spec-forge/src/resident.ts +4 -20
- package/spec-cli/src/maintenance-wrapper.ts +0 -276
- package/spec-cli/src/session-maintenance.ts +0 -751
- package/spec-dashboard/dist/assets/IssuesPage-BlkPSkmv.js +0 -1
- package/spec-dashboard/dist/assets/MobileApp-B1GxRZXK.js +0 -2
- package/spec-dashboard/dist/assets/SessionWindow-IspcLjFA.js +0 -1
- package/spec-dashboard/dist/assets/Thread-BpL3N3kw.js +0 -13
- /package/spec-dashboard/dist/assets/{TimelineChat-Cp579UoJ.css → launch-Cp579UoJ.css} +0 -0
package/spec-cli/src/sessions.ts
CHANGED
|
@@ -10,62 +10,18 @@ import { git, gitA, gitTry, repoRoot, mergeBaseDiff, mergeConflicts, withGitAbor
|
|
|
10
10
|
import { loadConfig, loadSpecs, loadSpecsLite, type ConfigPreset, type SpecLite } from './specs.js'
|
|
11
11
|
import { adapterLoadedReferenceState, defaultHarness, HARNESSES, sessionIdentityEnvVars, defaultLauncher, harnessById, procSnapshot, resolveLauncher, rendezvousListening, stampRvSock, type Harness, type HarnessLaunchReadinessFence, type TurnFailure, type FailureSubscription, type DispatchResult, type PaneProbe, type ProcTable } from './harness.js'
|
|
12
12
|
import { materialize } from './materialize.js'
|
|
13
|
-
import { mainBranch, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
|
|
14
|
-
import {
|
|
13
|
+
import { mainBranch, mainRoot, gitCommonDir, readConfig, runtimeRoot, treeSlotDir, sessionStoreDir, sessionRecordPath, sessionArtifactPath, listSessionIds, rawLaunchReadinessOriginal, readAliasedRawRecord, readRecordEntry, readAliasedRecordEntry, readPublicRecordEntry, envSessionId, isSessionLifecycle, isSessionProposal, type PublicRecordEntry, type RawRecord, type SessionLifecycle, type SessionProposal } from './layout.js'
|
|
14
|
+
import { appendSent, recordStatus, lastHumanSendVia } from './session-timeline.js'
|
|
15
15
|
import { stripRefSigil } from './mentions.js'
|
|
16
16
|
import { shQuote } from './sh.js'
|
|
17
17
|
import { assertSessionStopSafe, ResourceConflict } from './host-resources.js'
|
|
18
18
|
import { processStartToken } from './process-identity.js'
|
|
19
|
-
import {
|
|
20
|
-
|
|
21
|
-
// @@@ sessions - the WORKTREE is the durable unit; tmux is a disposable runtime handle. The per-session
|
|
22
|
-
// SOURCE OF TRUTH is an untracked record (`session.json`) in a per-user GLOBAL store keyed by the harness
|
|
23
|
-
// session_id (NOT a worktree file — the worktree stays pristine), surviving a kill / reboot / moving the
|
|
24
|
-
// folder. We launch claude with `--session-id <id>` (id we choose) so the SAME conversation can be
|
|
25
|
-
// `--resume`d into a fresh tmux. NO in-memory map: listSessions() ENUMERATES that store every time.
|
|
26
|
-
//
|
|
27
|
-
// STATE MACHINE — two ORTHOGONAL axes (see [[state]]): an agent-authored LIFECYCLE and a runtime-derived
|
|
28
|
-
// LIVENESS, neither overriding the other.
|
|
29
|
-
// lifecycle (authored): active | idle | awaiting | parked | error | asking | queued. `idle` is the ONE
|
|
30
|
-
// inferred one (the Notification(idle_prompt) hook, guarded active-only so it never clobbers a
|
|
31
|
-
// declaration; mark-active flips it back to active on real work).
|
|
32
|
-
// liveness (derived for EVERY session): online | starting | offline | unknown. offline = no tmux for the id,
|
|
33
|
-
// or the harness online-signal (claude's rendezvous socket LISTENER — a connect, not the socket
|
|
34
|
-
// FILE) is gone past the boot grace; starting = the boot window; unknown = the tmux probe itself
|
|
35
|
-
// failed (timed out under load) so death is UNPROVEN — render probe-failed, never offline/vanish.
|
|
36
|
-
// reconcile composes the two into the compact DisplayStatus for one-glyph surfaces.
|
|
37
|
-
// awaiting → the agent's PROPOSAL, awaiting a human:
|
|
38
|
-
// proposal=merge → shown "review" ("ready, merge me")
|
|
39
|
-
// proposal=nothing → shown "done" ("finished, your call")
|
|
40
|
-
// proposal=close → shown "close-pending" ("I suggest discarding this worktree")
|
|
41
|
-
// asking → the agent is pausing to ask the HUMAN a question. Written DETERMINISTICALLY two ways: the
|
|
42
|
-
// mark-active PreToolUse hook captures it the moment the agent invokes the AskUserQuestion
|
|
43
|
-
// tool (question → note), and the agent may also declare it via `spex session ask --note
|
|
44
|
-
// <question>`. Not inferred. Distinct from `parked` (which waits on a background task/
|
|
45
|
-
// schedule and self-resumes); an asking agent resumes only when a human sends it a prompt.
|
|
46
|
-
// queued → a prepared worktree held below the concurrency cap; the drainer launches it as a slot frees.
|
|
47
|
-
// (closed = the worktree AND the global record are removed; not a stored status)
|
|
48
|
-
// The agent only ever PROPOSES (awaiting); merge/close are human-only. Every proposal is reversible — nothing
|
|
49
|
-
// auto-disappears; to withdraw one you MESSAGE the session (mark-active clears it), and a relaunch (resume)
|
|
50
|
-
// deliberately does NOT touch it. `merges` is METADATA (how many times merged), shown as a badge, not a state.
|
|
51
|
-
//
|
|
52
|
-
// Launch rules: private `tmux -L <label>` socket + the session's pinned named-launcher command. The launcher
|
|
53
|
-
// preserves its harness's normal permission model unless the user explicitly configured an automatic-permission
|
|
54
|
-
// command. SPEXCODE_TMUX overrides the tmux socket for tests; no env var rewrites the launcher.
|
|
19
|
+
import { bindCodexGeneration, codexGenerationBindingForSession, commitCodexGenerationRegistration, prepareCodexGenerationClose, prepareCodexGenerationRegistration, readCodexGenerationLedger } from './codex-runtime-generations.js'
|
|
55
20
|
|
|
56
21
|
const pexec = promisify(execFile)
|
|
57
22
|
export const TMUX_SOCK = process.env.SPEXCODE_TMUX || 'spexcode'
|
|
58
|
-
// the legacy/default harness for helpers and old records. New sessions derive their harness from the selected
|
|
59
|
-
// launcher; all harness-specific launch facts still come from the adapter.
|
|
60
23
|
const HARNESS = defaultHarness
|
|
61
24
|
const COLS = 120, ROWS = 32
|
|
62
|
-
// @@@ concurrency cap - the most working agents we let run AT ONCE. Heavy multi-agent load (many claude
|
|
63
|
-
// processes computing simultaneously) was the source of resource-pressure crashes, so a launch beyond the
|
|
64
|
-
// cap is QUEUED, not started: it becomes a durable `queued` worktree that the drainer launches the moment a
|
|
65
|
-
// slot frees (an agent stops working/dies). NOT hardcoded — configured PER PROJECT in `spexcode.json`
|
|
66
|
-
// (`sessions.maxActive`), so a box can be tuned to its capacity without touching the toolchain. Precedence:
|
|
67
|
-
// spexcode.json → `SPEXCODE_MAX_ACTIVE` env → default 8. Read LIVE (cheap file read) so an edit takes effect
|
|
68
|
-
// on the next drain tick, no restart. Floored at 1 so a bad value can't wedge the queue to 0.
|
|
69
25
|
const DEFAULT_MAX_ACTIVE = 8
|
|
70
26
|
function maxActive(): number {
|
|
71
27
|
let v: number | undefined
|
|
@@ -104,23 +60,12 @@ const rvEnv = (id: string, harness = HARNESS) => {
|
|
|
104
60
|
...harness.launchEnv(id), ...homeVars].join(' ')
|
|
105
61
|
}
|
|
106
62
|
|
|
107
|
-
//
|
|
108
|
-
// (each harness OWNS its input channel — claude the rendezvous socket, codex app-server JSON-RPC). Re-exported here
|
|
109
|
-
// for the existing importers (client.ts) that read it off the sessions module.
|
|
63
|
+
// Re-exported for existing importers.
|
|
110
64
|
export type { DispatchResult }
|
|
111
65
|
|
|
112
66
|
export type Lifecycle = SessionLifecycle
|
|
113
67
|
export type Proposal = SessionProposal
|
|
114
|
-
// `corrupt` and `retired` are the two RECORD-INTEGRITY readings — neither a lifecycle the agent authored nor a
|
|
115
|
-
// liveness the runtime probed, but the honest answer when the record itself can no longer carry either: its
|
|
116
|
-
// bytes don't parse, or the worktree it names is gone. They exist so such a row can never silently vanish.
|
|
117
68
|
export type DisplayStatus = 'working' | 'idle' | 'offline' | 'starting' | 'review' | 'done' | 'close-pending' | 'parked' | 'error' | 'asking' | 'queued' | 'unknown' | 'corrupt' | 'retired'
|
|
118
|
-
// liveness — the orthogonal axis to Lifecycle: whether the agent process is actually up, derived (never
|
|
119
|
-
// authored) for EVERY session regardless of its lifecycle. See [[state]]: lifecycle and liveness never
|
|
120
|
-
// override each other; the UI keys the terminal-mount / relaunch panel on this, the badge on lifecycle.
|
|
121
|
-
// `unknown` = the liveness PROBE ITSELF failed (the tmux snapshot timed out / errored under load), so we
|
|
122
|
-
// CANNOT tell — the row renders probe-failed, NEVER offline/closed and never vanishes (board honesty: a slow
|
|
123
|
-
// box must not masquerade as a graveyard, the failure that drove the mass-restore incident).
|
|
124
69
|
export type Liveness = 'online' | 'starting' | 'offline' | 'unknown'
|
|
125
70
|
const PROPOSAL_STATUS: Record<Proposal, DisplayStatus> = { merge: 'review', nothing: 'done', close: 'close-pending' }
|
|
126
71
|
|
|
@@ -139,15 +84,8 @@ export type Session = {
|
|
|
139
84
|
sortKey: number | null // manual drag-reorder override ([[session-reorder]]); null = sort by `created`
|
|
140
85
|
}
|
|
141
86
|
|
|
142
|
-
// ensure a session's GLOBAL store dir exists, returning its path. Idempotent (recursive mkdir) — every
|
|
143
|
-
// writer that drops an artifact (record/prompt/launch/launch.sh/comms) calls this first so order never matters.
|
|
144
87
|
function storeDir(id: string): string { const d = sessionStoreDir(id); mkdirSync(d, { recursive: true }); return d }
|
|
145
88
|
|
|
146
|
-
// @@@ originating prompt - what the session was ASKED to do, captured at launch so a manager (human or
|
|
147
|
-
// agent) can later answer "what was this session for?" WITHOUT transcript archaeology. Prompts are
|
|
148
|
-
// multi-line, so they live as their own artifact (`prompt`) in the session's GLOBAL store dir (keyed by
|
|
149
|
-
// session_id, [[state]]), never in the worktree. Everything here is BEST-EFFORT: a missing artifact (a
|
|
150
|
-
// session launched before this existed) just means no prompt is shown — never an error, never blocks a launch.
|
|
151
89
|
function writePromptFile(id: string, prompt: string): void {
|
|
152
90
|
try { writeFileSync(join(storeDir(id), 'prompt'), prompt) } catch { /* best-effort; must never block the launch */ }
|
|
153
91
|
}
|
|
@@ -159,11 +97,7 @@ function readPromptFile(id: string): string | null {
|
|
|
159
97
|
return s.trim() ? s : null
|
|
160
98
|
} catch { return null }
|
|
161
99
|
}
|
|
162
|
-
//
|
|
163
|
-
// into yet. The exact prompt to launch it with — the directive-generated finish-the-op prompt, or the plain
|
|
164
|
-
// human prompt — is parked as the `launch` artifact in the store dir so the drainer can launch it later
|
|
165
|
-
// (possibly after a backend restart) WITHOUT re-deriving anything. CONSUMED (removed) the moment the session
|
|
166
|
-
// launches, so it exists only while the session waits in the queue. Distinct from `prompt` (the originating ask).
|
|
100
|
+
// Persist queued launch input across restarts; consume it once the launch begins.
|
|
167
101
|
function writeLaunchFile(id: string, prompt: string): void {
|
|
168
102
|
try { writeFileSync(join(storeDir(id), 'launch'), prompt) } catch { /* best-effort; the drainer treats a missing file as nothing-to-launch */ }
|
|
169
103
|
}
|
|
@@ -174,26 +108,18 @@ function removeLaunchFile(id: string): void {
|
|
|
174
108
|
try { rmSync(sessionArtifactPath(id, 'launch'), { force: true }) } catch { /* best-effort */ }
|
|
175
109
|
}
|
|
176
110
|
|
|
177
|
-
//
|
|
178
|
-
|
|
179
|
-
|
|
111
|
+
// One line, bounded — the launch prompt's shape when it enters a compact headline.
|
|
112
|
+
export const HEADLINE_PREVIEW_COLUMNS = 60
|
|
113
|
+
function oneLinePreview(text: string, n = HEADLINE_PREVIEW_COLUMNS): string {
|
|
114
|
+
const first = text.split('\n').map((l) => l.trim()).find(Boolean) || ''
|
|
180
115
|
return first.length > n ? first.slice(0, n - 1) + '…' : first
|
|
181
116
|
}
|
|
182
117
|
|
|
183
|
-
// @@@ session-label — the ONE place a session's display strings are derived ([[session-label]]). The raw
|
|
184
|
-
// parts (a user rename `name`, the 7-word prompt truncation `title`) never leave this module at the top
|
|
185
|
-
// level: toSession computes `label` (STABLE: name > node > title > branch > id — tables/selectors) and
|
|
186
|
-
// `headline` (LIVE: name > activity > promptPreview > node > title > branch > id — what a human reads,
|
|
187
|
-
// see [[session-activity]]) and the wire carries THOSE; the parts ride only under `raw` for the few
|
|
188
|
-
// explicit consumers (the rename prefill). A surface that wants a session's name reads s.label/s.headline
|
|
189
|
-
// — there is no bare s.title/s.name to reach for, which is the enforcement.
|
|
190
118
|
export const deriveLabel = (r: { name?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
191
119
|
r.name || r.node || r.title || r.branch || r.id
|
|
192
120
|
export const deriveHeadline = (r: { name?: string | null; activity?: string | null; promptPreview?: string | null; node?: string | null; title?: string | null; branch?: string | null; id: string }): string =>
|
|
193
121
|
r.name || r.activity || r.promptPreview || r.node || r.title || r.branch || r.id
|
|
194
122
|
|
|
195
|
-
// accessors kept for the human-naming call sites (watch/notify/reply-channel): trivially the precomputed
|
|
196
|
-
// wire fields, so every surface — CLI, dashboard, comms — reads the same derivation by construction.
|
|
197
123
|
export const sessionLabel = (s: Session): string => s.label
|
|
198
124
|
export const sessionHeadline = (s: Session): string => s.headline
|
|
199
125
|
|
|
@@ -217,30 +143,10 @@ function probeTimedOut(e: unknown): boolean {
|
|
|
217
143
|
async function tmuxOk(args: string[]): Promise<boolean> { try { await tmux(args); return true } catch { return false } }
|
|
218
144
|
export async function alive(id: string): Promise<boolean> { return tmuxOk(['has-session', '-t', id]) }
|
|
219
145
|
|
|
220
|
-
// worktrees + branches are created off MAIN even when the server runs inside a worktree.
|
|
221
|
-
function mainRoot(): string {
|
|
222
|
-
try {
|
|
223
|
-
const checkout = dirname(gitCommonDir())
|
|
224
|
-
const configured = readConfig(checkout).main?.trim()
|
|
225
|
-
return configured ? resolve(checkout, configured) : checkout
|
|
226
|
-
}
|
|
227
|
-
catch { return repoRoot() }
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// @@@ pkgRoot - the CLI package's OWN directory, derived from this module's location, never a hardcoded
|
|
231
|
-
// repoRoot()+'spec-cli'. This file lives at <pkgRoot>/src/sessions.ts, so `..` from it is the package
|
|
232
|
-
// root — making the launch-script paths (hooks/, node_modules/.bin/tsx, src/cli.ts) survive the package
|
|
233
|
-
// being renamed or relocated out of the default <repo>/spec-cli layout.
|
|
234
146
|
function pkgRoot(): string {
|
|
235
147
|
return fileURLToPath(new URL('..', import.meta.url))
|
|
236
148
|
}
|
|
237
149
|
|
|
238
|
-
// the in-memory session record — the typed view of session.json. `governed` (dashboard-launched=true vs
|
|
239
|
-
// user-self-launched=false), `worktreePath`/`branch`/`createdAt` are the fields the board USED to read off
|
|
240
|
-
// the worktree (its path/birthtime); now they live IN the record, since the record is the enumeration source.
|
|
241
|
-
// `name` is the rename override (distinct from the prompt-derived `title`); `session` is the harness session_id
|
|
242
|
-
// (the store key). The launcher mints the id (`claude --session-id <id>`) so it equals what every hook payload
|
|
243
|
-
// and CLAUDE_CODE_SESSION_ID carry — one id across the record dir, tmux window, rendezvous socket, and commits.
|
|
244
150
|
export type SessRec = {
|
|
245
151
|
session: string; governed: boolean; worktreePath: string; branch: string | null
|
|
246
152
|
node: string | null; title: string | null; name: string | null
|
|
@@ -262,10 +168,6 @@ type LaunchReadinessOriginal = Pick<SessRec, 'status' | 'proposal' | 'note' | 's
|
|
|
262
168
|
type LaunchReadinessPending = { version: 1; startedAt: number; original: LaunchReadinessOriginal }
|
|
263
169
|
export const OWNED_QUEUE_RAW_STATUS = 'launch-queued'
|
|
264
170
|
|
|
265
|
-
// @@@ stable launch authority - the supervisor injects its PUBLIC proxy URL into every replaceable child.
|
|
266
|
-
// That URL survives child hot reload/restart; PORT inside a supervised child is private and ephemeral, so it
|
|
267
|
-
// is only the fallback for a directly-run server with no injected API URL. Credentials/query/fragment are
|
|
268
|
-
// not authority and may contain secrets, so they are stripped before the value reaches session.json.
|
|
269
171
|
export function backendLaunchAuthority(env: { SPEXCODE_API_URL?: string; PORT?: string } = process.env): string {
|
|
270
172
|
const raw = env.SPEXCODE_API_URL?.trim() || `http://127.0.0.1:${env.PORT?.trim() || '8787'}`
|
|
271
173
|
const url = new URL(raw)
|
|
@@ -299,12 +201,6 @@ function readRecord(id: string): SessRec | null {
|
|
|
299
201
|
`session record is unreadable: ${sessionRecordPath(id)} — ${error instanceof Error ? error.message : String(error)}. The file is kept as-is; nothing will rewrite it.`)
|
|
300
202
|
}
|
|
301
203
|
}
|
|
302
|
-
// @@@ SessionRecordUnusable - the record exists but cannot carry state, for one of two reasons, and BOTH must
|
|
303
|
-
// stop a writer rather than let it invent one. `corrupt`: the bytes don't parse, so writing would DESTROY the
|
|
304
|
-
// evidence of what broke and resurrect the session as a plausible-looking empty shell (the reported failure —
|
|
305
|
-
// a damaged record came back as a valid `idle` record and even had a launch script regenerated for it).
|
|
306
|
-
// `retired`: the work merged and the worktree the record names is gone, so there is nothing left to be active
|
|
307
|
-
// IN. Readers that enumerate (the board) catch this and render the row; writers let it out, loud.
|
|
308
204
|
export class SessionRecordUnusable extends Error {
|
|
309
205
|
constructor(readonly code: 'corrupt' | 'retired', readonly session: string, message: string) {
|
|
310
206
|
super(message)
|
|
@@ -313,16 +209,10 @@ export class SessionRecordUnusable extends Error {
|
|
|
313
209
|
}
|
|
314
210
|
const corruptReason = (e: { path: string; error: string }): string =>
|
|
315
211
|
`session record is unreadable: ${e.path} — ${e.error}. The file is kept as-is; nothing will rewrite it. A close attempt quarantines the bytes and reports the preserved runtime/worktree/branch residue, but cannot signal or delete without an exact owner.`
|
|
316
|
-
// a record whose worktree is gone names work that no longer exists on disk. That is the manual-retirement end
|
|
317
|
-
// state (merged, worktree and branch removed, record left behind), and it is terminal: no lifecycle writer may
|
|
318
|
-
// put such a session back to work, and no launch may be assembled for a directory that isn't there.
|
|
319
212
|
function retirementReason(rec: SessRec): string | null {
|
|
320
213
|
if (!rec.worktreePath || existsSync(rec.worktreePath)) return null
|
|
321
214
|
return `session ${rec.session.slice(0, 8)} is retired: its worktree ${rec.worktreePath} no longer exists, so it cannot work, be marked active/idle, or be relaunched. Close it (\`spex session close <id>\`) to drop the record.`
|
|
322
215
|
}
|
|
323
|
-
// the read every LIFECYCLE writer uses: it additionally refuses a retired record. Metadata verbs (rename,
|
|
324
|
-
// sort, archive) and `close` deliberately keep using readRecord — filing and removal stay available on a row
|
|
325
|
-
// whose work is gone.
|
|
326
216
|
function readLiveRecord(id: string): SessRec | null {
|
|
327
217
|
const rec = readRecord(id)
|
|
328
218
|
if (!rec) return null
|
|
@@ -503,27 +393,7 @@ function restoreLaunchReadinessOriginal(rec: SessRec): SessRec {
|
|
|
503
393
|
const original = rec.launchReadinessPending?.original
|
|
504
394
|
return original ? { ...rec, ...original, launchReadinessPending: null } : rec
|
|
505
395
|
}
|
|
506
|
-
//
|
|
507
|
-
// and lands by atomic replace (temp file in the same dir, then rename). Nothing else — no hook, no shell, no
|
|
508
|
-
// route — may compose or edit the file's text: a note is arbitrary human/agent prose, so any writer that
|
|
509
|
-
// substitutes it into existing JSON eventually meets a quote, a backslash, or a newline and leaves a record
|
|
510
|
-
// nothing can parse (the reported corruption came from exactly that: a hot-path hook editing the value with
|
|
511
|
-
// sed). The shell hooks READ this file cheaply and delegate every WRITE back through the CLI to this function.
|
|
512
|
-
// The rename is what makes a reader between two writes see one whole record instead of a truncated one.
|
|
513
|
-
//
|
|
514
|
-
// @@@ session.json format - written one-field-per-line (JSON.stringify(_, null, 2)) with EVERY key ALWAYS
|
|
515
|
-
// present (nulls rendered as "" / the empty value, never an absent key). The stable shape is what lets the
|
|
516
|
-
// pure-shell hooks answer "is this record already active, with nothing stale to clear?" with three exact-line
|
|
517
|
-
// greps and no jq — a READ fast path, never an edit. So do NOT switch to conditional keys or a compact dump.
|
|
518
|
-
//
|
|
519
|
-
// @@@ the record self-cleans - the object below is a CLOSED key set rebuilt from the typed record on every
|
|
520
|
-
// write, never a merge over what was read. So a field retired from the code is ALSO retired from disk the
|
|
521
|
-
// next time anything touches that record: no migration verb, no GC pass, no accreting graveyard of dead keys.
|
|
522
|
-
// A new field earns its place by being declared HERE and in `fromRaw` — that pairing is what keeps the file a
|
|
523
|
-
// projection of the current type rather than a log of everything it has ever been, and it is also the reason
|
|
524
|
-
// a field MISSING from this object is silently dropped: `stopped`, `archived`, `cold_proof`, and
|
|
525
|
-
// `adapter_recovery` are distinct lifecycle/resource projection fields, so each must be listed and cleared by
|
|
526
|
-
// its own transition rather than one being inferred from another.
|
|
396
|
+
// Rebuild the full disk projection so retired keys disappear on the next write.
|
|
527
397
|
function writeRecord(rec: SessRec): void {
|
|
528
398
|
let previous: SessRec | null = null
|
|
529
399
|
try { previous = readRecord(rec.session) } catch { /* a new or damaged record has no prior transition */ }
|
|
@@ -536,9 +406,6 @@ function writeRecord(rec: SessRec): void {
|
|
|
536
406
|
title: rec.title ?? '',
|
|
537
407
|
name: rec.name ?? '',
|
|
538
408
|
parent: rec.parent ?? '',
|
|
539
|
-
// A leased queue uses a raw token older drainers do not recognize as `queued`; current readers map it
|
|
540
|
-
// back to the unchanged public lifecycle. This version fence is what keeps an orphaned old backend from
|
|
541
|
-
// stealing the entry before it can even inspect the new launch_owner field.
|
|
542
409
|
status: rawLifecycleStatus(rec),
|
|
543
410
|
proposal: rec.proposal ?? '',
|
|
544
411
|
merges: rec.merges,
|
|
@@ -576,11 +443,6 @@ function writeRecord(rec: SessRec): void {
|
|
|
576
443
|
const tmp = join(dir, `.session.json.${process.pid}.tmp`)
|
|
577
444
|
writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n')
|
|
578
445
|
renameSync(tmp, path) // atomic within the dir: a concurrent reader sees the old record or the new one
|
|
579
|
-
// session.json normally is the current public projection. A launch-readiness candidate is the sole internal
|
|
580
|
-
// exception: its frozen original remains public until validation clears the fence. Persist each PUBLIC moved
|
|
581
|
-
// lifecycle value before this writer returns, so a later write cannot erase a declaration note between
|
|
582
|
-
// observer samples. New-record genesis stays with superviseTimeline; metadata-only writes do not manufacture
|
|
583
|
-
// status events.
|
|
584
446
|
const previousPublic = previous ? publicRecord(previous) : null
|
|
585
447
|
const nextPublic = publicRecord(rec)
|
|
586
448
|
if (rec.governed && previousPublic && (previousPublic.status !== nextPublic.status
|
|
@@ -589,42 +451,10 @@ function writeRecord(rec: SessRec): void {
|
|
|
589
451
|
}
|
|
590
452
|
}
|
|
591
453
|
|
|
592
|
-
//
|
|
593
|
-
// shown regardless of liveness. active/idle → their LIVENESS: offline if no tmux for the recorded id OR
|
|
594
|
-
// claude's rendezvous socket is gone (claude exited), else idle if the idle_prompt hook has fired since
|
|
595
|
-
// the last tool use, else working.
|
|
596
|
-
|
|
597
|
-
// @@@ liveTmux - which of OUR tmux sessions exist AND each pane's runtime probe, in TWO spawns total (one
|
|
598
|
-
// tmux, one ps) for the WHOLE list. reconcile used to spawn two tmux per session (has-session +
|
|
599
|
-
// display-message), so listing N sessions was 2N spawns — the dominant /api/sessions cost under multi-agent
|
|
600
|
-
// load. `tmux list-sessions` returns every session on our socket at once; a session present here has a live
|
|
601
|
-
// tmux window (session_name = the id we created it with), mapped to a PaneProbe: its pane's ROOT pid
|
|
602
|
-
// (`#{pane_pid}`) plus ONE shared whole-box pid→(ppid, comm) table from a single `ps` spawn. tmux server
|
|
603
|
-
// down / no sessions → empty map → everything reconciles to offline, which is correct. `live.has(id)` = the
|
|
604
|
-
// window presence; `live.get(id)` = the probe, which the CODEX adapter's liveness walks to tell a running TUI
|
|
605
|
-
// (a codex/node process among the pane pid's descendants) from a failed launch that dropped back to the bare
|
|
606
|
-
// shell (see [[harness-adapter]] paneTreeRunsCodex — the pane's FOREGROUND command is `bash`, the launch
|
|
607
|
-
// wrapper, even while the TUI renders, so the foreground name is NOT the signal). CLAUDE ignores the probe —
|
|
608
|
-
// its workers launch through the `reclaude` wrapper, which runs claude as a CHILD, so claude liveness stays
|
|
609
|
-
// its rendezvous socket. The per-session alive() above stays for the single-session ops (capture / rawKey).
|
|
610
|
-
// (the whole-box ps snapshot itself — procSnapshot — lives in harness.ts beside its tree-walk consumers.)
|
|
611
|
-
// @@@ LiveSnap - the ONE liveness snapshot the whole session list shares, built from a SINGLE tmux spawn
|
|
612
|
-
// (`list-panes -a` yields every session's window presence, pane pid, AND pane title at once — every session has
|
|
613
|
-
// ≥1 pane). `windows` = our live tmux windows (id → PaneProbe: pane pid + the hot-tier `pidAlive` verdict + the
|
|
614
|
-
// legacy `procs` table when a pid-less codex session needs it); `titles` = each pane's RAW title (harness-
|
|
615
|
-
// interpreted later in paneActivity — claude: a self-authored task summary; codex: a spinner + the cwd folder
|
|
616
|
-
// name); `sockets` = the ids whose rendezvous socket has a LIVE LISTENER (connect-probed once here, not the
|
|
617
|
-
// file-exists lie — [[harness-adapter]]); `unproven` = the ids whose LISTENER probe could not conclude (timeout
|
|
618
|
-
// under load / EAGAIN off a full-but-alive backlog — see rendezvousListening's tri-state) — death UNPROVEN, so
|
|
619
|
-
// those rows read `unknown`, never `offline`; `probeFailed` = the tmux probe itself FAILED (timed out under
|
|
620
|
-
// load), DISTINCT from "tmux up, no sessions" — the former means death is UNPROVEN so those rows read `unknown`,
|
|
621
|
-
// the latter is authoritative and reads `offline`.
|
|
454
|
+
// Share one liveness snapshot rather than spawning tmux for every displayed session.
|
|
622
455
|
export type LiveSnap = { probeFailed: boolean; windows: Map<string, PaneProbe>; titles: Map<string, string>; sockets: Set<string>; unproven: Set<string> }
|
|
623
456
|
|
|
624
|
-
//
|
|
625
|
-
// '#{session_name}\t#{pane_pid}\t#{pane_title}'` snapshot: id → { panePid, title }. First pane per session wins
|
|
626
|
-
// (our sessions are single-pane by construction). The title is the remainder AFTER the 2nd tab, so a title that
|
|
627
|
-
// itself contains tabs survives intact. Exported so the parse is unit-auditable without a tmux spawn.
|
|
457
|
+
// First pane per session wins; split only twice so titles may contain tabs.
|
|
628
458
|
export function parseLivePanes(out: string): Map<string, { panePid?: number; title?: string }> {
|
|
629
459
|
const m = new Map<string, { panePid?: number; title?: string }>()
|
|
630
460
|
for (const line of out.split('\n')) {
|
|
@@ -642,15 +472,7 @@ export function parseLivePanes(out: string): Map<string, { panePid?: number; tit
|
|
|
642
472
|
return m
|
|
643
473
|
}
|
|
644
474
|
|
|
645
|
-
//
|
|
646
|
-
// liveSnapshot's codex `pidAlive` verdict, so ONE latch rule serves both. keyed by session id → the last
|
|
647
|
-
// agent.pid { mtime, pid, deadLatched }. agentAlive statSyncs the pid file: missing → undefined (a
|
|
648
|
-
// pre-registration/old session — the warm tier / legacy tree-walk covers it); mtime changed → a relaunch wrote
|
|
649
|
-
// a fresh pid, so re-read + RESET the latch; then kill-0 the pid — alive (or EPERM) = true, ESRCH = false and
|
|
650
|
-
// LATCH permanently for this (pid, mtime) so a later pid-reuse by an unrelated process can never resurrect a
|
|
651
|
-
// dead session (the portable pid-reuse guard: death is irreversible per registration; only a NEW agent.pid
|
|
652
|
-
// write — a fresh mtime — resets). Sync fs + one kill-0 syscall, NO child process, so it stays honest under a
|
|
653
|
-
// thrashed event loop — exactly when a spawn-based probe would hang.
|
|
475
|
+
// Latch ESRCH per pid-file mtime so a recycled OS PID cannot revive an old session.
|
|
654
476
|
type PidEntry = { mtimeMs: number; pid: number; deadLatched: boolean }
|
|
655
477
|
const pidRegistry = new Map<string, PidEntry>()
|
|
656
478
|
function readAgentPid(p: string): number { try { return Number(readFileSync(p, 'utf8').trim()) } catch { return NaN } }
|
|
@@ -670,10 +492,7 @@ function agentAlive(id: string): boolean | undefined {
|
|
|
670
492
|
}
|
|
671
493
|
}
|
|
672
494
|
|
|
673
|
-
//
|
|
674
|
-
// (procSnapshot) is paid ONLY when a windowed CODEX session has NO registered agent.pid (a pre-registration
|
|
675
|
-
// launch that still needs the paneTreeRunsCodex tree-walk). A box with no codex, or all pid-registered
|
|
676
|
-
// launches, returns false → zero ps spawn. Self-extinguishes as pre-registration sessions close.
|
|
495
|
+
// Only pre-agent.pid Codex sessions need the legacy whole-process scan.
|
|
677
496
|
export function needsCodexProcScan(windowed: { harness: string; hasPid: boolean }[]): boolean {
|
|
678
497
|
return windowed.some((w) => (w.harness || 'claude') === 'codex' && !w.hasPid)
|
|
679
498
|
}
|
|
@@ -722,12 +541,7 @@ async function liveSnapshot(): Promise<LiveSnap> {
|
|
|
722
541
|
return { probeFailed: false, windows, titles, sockets, unproven }
|
|
723
542
|
}
|
|
724
543
|
|
|
725
|
-
//
|
|
726
|
-
// socket connects — sync fs + one kill-0 syscall per session, so it stays honest under a thrashed event loop
|
|
727
|
-
// (exactly when a spawn-based probe would hang). The id list is refreshed LAZILY (at most once/second) from
|
|
728
|
-
// listSessionIds; per call each id's verdict comes from agentAlive (the shared death-latch registry). A session
|
|
729
|
-
// with NO agent.pid (pre-registration/old) is SKIPPED here — the warm tier covers it. The fingerprint is the
|
|
730
|
-
// sorted `${id}:${alive?1:0}` pairs plus the id set, so it moves the instant a registered agent dies.
|
|
544
|
+
// Avoid process spawns on the hot path; old sessions without agent.pid remain warm-tier only.
|
|
731
545
|
let hotIds: string[] = []
|
|
732
546
|
let hotIdsAt = 0
|
|
733
547
|
export async function hotSignature(): Promise<string> {
|
|
@@ -747,10 +561,7 @@ export async function hotSignature(): Promise<string> {
|
|
|
747
561
|
return pairs.sort().join(',') + '|' + present.sort().join(',')
|
|
748
562
|
}
|
|
749
563
|
|
|
750
|
-
//
|
|
751
|
-
// titles) plus the rendezvous listener tri-state, fingerprinted so a socket dying (claude exit), the probe
|
|
752
|
-
// flipping to unknown, a listener wedging (unproven), or a headline changing pushes a board-changed the instant
|
|
753
|
-
// it happens — not on window churn alone. Sorted so it only moves on a real change; NO git, NO extra store walk.
|
|
564
|
+
// Include listener and title changes so watchers refresh without another store read.
|
|
754
565
|
export async function warmSignature(): Promise<string> {
|
|
755
566
|
const snap = await liveSnapshot()
|
|
756
567
|
return (snap.probeFailed ? 'PROBEFAIL|' : '') + [...snap.windows.keys()].sort().join(',') + '#' +
|
|
@@ -805,18 +616,6 @@ export const BOOT_GRACE_MS = 45000 // > SOCKET_READY_TIMEOUT_MS, and spans lau
|
|
|
805
616
|
const LAUNCH_FAST_FAIL_S = 12 // launchScript retries the agent command when it exits faster than this: fast
|
|
806
617
|
// exit before readiness is retryable, but it is not proof of one specific cause
|
|
807
618
|
|
|
808
|
-
// @@@ liveness - the orthogonal axis ([[state]]): is the agent process up, for ANY session regardless of
|
|
809
|
-
// lifecycle, from a prebuilt runtime snapshot (no per-call spawn — see liveSnapshot) + the adapter's own channel
|
|
810
|
-
// check. Order of honesty: if the PROBE ITSELF failed (tmux timed out under load) death is UNPROVEN → `unknown`
|
|
811
|
-
// (render probe-failed, NEVER a false offline that empties the board and provokes a mass-restore). Else offline
|
|
812
|
-
// iff the tmux window is gone OR the adapter's online-signal is absent past the boot window. claude (via the
|
|
813
|
-
// reclaude wrapper) holds CLAUDE_BG_RENDEZVOUS_SOCK open the whole time it is alive, so a LIVE LISTENER on that
|
|
814
|
-
// socket (`snap.sockets`, connect-probed — NOT the socket FILE, which a crash leaves behind) is the truth —
|
|
815
|
-
// not the pane, whose foreground is the wrapper/shell while claude runs as its child. codex has no such socket,
|
|
816
|
-
// so its truth is the pane's DESCENDANT PROCESS TREE from the SAME snapshot: a live TUI keeps a codex/node
|
|
817
|
-
// process below the pane pid; a failed launch leaves the pane at a bare shell, even while the shared app-server
|
|
818
|
-
// sock lingers. A just-launched agent whose online-signal hasn't appeared yet reads the transient 'starting'
|
|
819
|
-
// for the grace window; only past it (still not online) is it genuinely 'offline'.
|
|
820
619
|
export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
821
620
|
if (!rec.session || rec.stopped || rec.archived) return 'offline'
|
|
822
621
|
// Ask the resolved ADAPTER ([[harness-adapter]]): claude/pi/opencode prove their rendezvous listener;
|
|
@@ -842,12 +641,6 @@ export function liveness(rec: SessRec, snap: LiveSnap): Liveness {
|
|
|
842
641
|
return 'offline'
|
|
843
642
|
}
|
|
844
643
|
|
|
845
|
-
// reconcile the compact DisplayStatus — a DERIVED label composing lifecycle + liveness for one-glyph
|
|
846
|
-
// surfaces ([[state]]), never a third source of truth. Lifecycle wins the label except where liveness must
|
|
847
|
-
// show through: awaiting → its proposal label; parked/error/asking/queued → themselves; active/idle → their
|
|
848
|
-
// liveness (offline/starting/unknown), else the active-only idle/working inference (the mark-active hook flips
|
|
849
|
-
// idle → active on the next real work, self-correcting). The orthogonal liveness field is what the UI keys
|
|
850
|
-
// terminal-mount and the relaunch panel on; this label is for badges and `spex session ls`.
|
|
851
644
|
function reconcile(rec: SessRec, snap: LiveSnap): DisplayStatus {
|
|
852
645
|
// record integrity outranks both axes: a session whose worktree is gone has no work to be in any state
|
|
853
646
|
// about. It reads `retired` — a terminal, human-closable row, never a lifecycle a hook can write back over.
|
|
@@ -869,10 +662,6 @@ async function findWorktree(id: string): Promise<{ path: string; branch: string
|
|
|
869
662
|
return { path: rec.worktreePath, branch: rec.branch, rec }
|
|
870
663
|
}
|
|
871
664
|
|
|
872
|
-
// @@@ corruptSession - the row for a record we cannot parse. Every display field the surfaces read is filled
|
|
873
|
-
// from the ONE thing we still know (the id) plus the diagnosis, so the row renders everywhere without any
|
|
874
|
-
// surface having to special-case a half-record. Liveness is `unknown`, not `offline`: we never probed, so we
|
|
875
|
-
// have not proven anything about the agent — the same honesty rule a failed probe follows.
|
|
876
665
|
function corruptSession(id: string, entry: { path: string; error: string }): Session {
|
|
877
666
|
const label = `${id.slice(0, 8)} (unreadable record)`
|
|
878
667
|
return {
|
|
@@ -890,38 +679,28 @@ export function toSession(rec: SessRec, status: DisplayStatus, lv: Liveness, act
|
|
|
890
679
|
// dead/booting session would show a stale or absent title, so it's suppressed unless liveness is online.
|
|
891
680
|
const showActivity = lv === 'online'
|
|
892
681
|
const act = showActivity ? activity : null
|
|
893
|
-
const pp = prompt ?
|
|
682
|
+
const pp = prompt ? oneLinePreview(prompt) : null
|
|
894
683
|
const parts = { id: rec.session, name: rec.name, node: rec.node, title: rec.title, branch: rec.branch, activity: act, promptPreview: pp }
|
|
895
684
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
896
685
|
return { id: rec.session, node: rec.node, branch: rec.branch, label: deriveLabel(parts), headline: deriveHeadline(parts), raw: { name: rec.name, title: rec.title }, path: rec.worktreePath, parent: rec.parent, harness: harness.id, capabilities: { headless: harness.headless }, launcher: rec.launcher, lifecycle: rec.status, proposal: rec.proposal, merges: rec.merges, note: rec.note, status, liveness: lv, archived: rec.archived, archiveHazard: null, prompt, promptPreview: pp, created: rec.createdAt, activity: act, sortKey: rec.sortKey }
|
|
897
686
|
}
|
|
898
687
|
|
|
899
|
-
// @@@ renameSession - set (or clear) a session's human display NAME: the user-chosen override that wins
|
|
900
|
-
// over the derived label (node/title/branch/id) on every surface. Persisted to the session's global
|
|
901
|
-
// record (`session.json` in the store, like every other field) so the name survives backend restarts
|
|
902
|
-
// and is read back like any other field. A blank name CLEARS the override, reverting the row to its derived label. Works for a session in
|
|
903
|
-
// any state (queued/live/offline) since it edits the on-disk record, not the live tmux. Unknown id → false
|
|
904
|
-
// (the route answers 404). The frontend's right-click rename is the sole caller today.
|
|
905
688
|
export async function renameSession(id: string, name: string): Promise<boolean> {
|
|
906
|
-
return
|
|
689
|
+
return withRecordLock(id, async () => {
|
|
907
690
|
const wt = await findWorktree(id)
|
|
908
691
|
if (!wt) return false
|
|
909
692
|
writeRecord({ ...wt.rec, name: name.trim() || null })
|
|
910
693
|
return true
|
|
911
|
-
})
|
|
694
|
+
})
|
|
912
695
|
}
|
|
913
696
|
|
|
914
|
-
// @@@ setSessionSort - set (or clear) a session's drag-reorder pseudo-time ([[session-reorder]]), parallel
|
|
915
|
-
// to renameSession: persisted to the session's global record so the manual order survives restarts and
|
|
916
|
-
// shows on every surface (all sort by `sortKey ?? created`). A null key CLEARS it, dropping the row back to
|
|
917
|
-
// its `created` slot. Works in any state since it edits the on-disk record. Unknown id → false (route 404s).
|
|
918
697
|
export async function setSessionSort(id: string, key: number | null): Promise<boolean> {
|
|
919
|
-
return
|
|
698
|
+
return withRecordLock(id, async () => {
|
|
920
699
|
const wt = await findWorktree(id)
|
|
921
700
|
if (!wt) return false
|
|
922
701
|
writeRecord({ ...wt.rec, sortKey: key != null && Number.isFinite(key) ? key : null })
|
|
923
702
|
return true
|
|
924
|
-
})
|
|
703
|
+
})
|
|
925
704
|
}
|
|
926
705
|
|
|
927
706
|
// the session's full ORIGINATING prompt (what it was asked to do), or null if none was recorded. A record we
|
|
@@ -932,17 +711,9 @@ export async function sessionPrompt(id: string): Promise<string | null> {
|
|
|
932
711
|
catch (e) { if (e instanceof SessionRecordUnusable) return null; throw e }
|
|
933
712
|
}
|
|
934
713
|
|
|
935
|
-
//
|
|
936
|
-
// the store is definitive; a transient failure reading it (an ENOENT race, or a sibling read failing under a
|
|
937
|
-
// concurrent merge) must NOT drop the row from the board — that absence is exactly what watchSessions used to
|
|
938
|
-
// mis-read as a `closed · removed`. So a degraded read serves this last-known row instead of vanishing. Pruned
|
|
939
|
-
// each poll to only ids still present.
|
|
714
|
+
// Preserve rows through a transient record-read failure; prune after the store entry disappears.
|
|
940
715
|
const lastKnownSession = new Map<string, Session>()
|
|
941
716
|
|
|
942
|
-
// @@@ listSessions - the board's session list, enumerated from the GLOBAL per-session store (replacing the
|
|
943
|
-
// old `git worktree list` scan). Every GOVERNED record this project owns becomes a row, status reconciled;
|
|
944
|
-
// non-governed (user-self-launched) records are excluded — board state is a managed-session concern ([[state]]).
|
|
945
|
-
// Offline and awaiting ones still appear (their record persists), so a session is never lost from view.
|
|
946
717
|
export async function listSessions(includeArchived = false): Promise<Session[]> {
|
|
947
718
|
// ONE store enumeration + ONE tmux snapshot (windows + pane pids + titles, merged) for the whole list, then
|
|
948
719
|
// every session reconciles by a pure set lookup + one existsSync — no per-session tmux spawn.
|
|
@@ -1032,17 +803,12 @@ export async function listSessions(includeArchived = false): Promise<Session[]>
|
|
|
1032
803
|
}, () => {
|
|
1033
804
|
// DEGRADED: the record dir still exists but reading session.json failed transiently. NEVER drop a live
|
|
1034
805
|
// session — serve its last-known row. (No last-known means a first sighting raced a failure; nothing to
|
|
1035
|
-
// show yet,
|
|
806
|
+
// show yet, and it reappears on the next build.)
|
|
1036
807
|
return lastKnownSession.get(id) ?? null
|
|
1037
808
|
}))
|
|
1038
809
|
// prune last-known entries for ids that no longer appear at all (genuinely removed), keeping it bounded.
|
|
1039
810
|
const liveIds = new Set(ids)
|
|
1040
811
|
for (const k of [...lastKnownSession.keys()]) if (!liveIds.has(k)) lastKnownSession.delete(k)
|
|
1041
|
-
// @@@ creation order - order by birth (oldest first): each session keeps its slot for life and a new one
|
|
1042
|
-
// simply appends — a stable spatial map across every surface (dashboard window, session tabs, `spex session ls`).
|
|
1043
|
-
// `created` is the record's stored createdAt (set once at launch). A manual drag ([[session-reorder]])
|
|
1044
|
-
// overrides one row's slot via a pseudo-time `sortKey`, so sort by `sortKey ?? created`; id breaks ties so
|
|
1045
|
-
// same-instant births (or sort-keys) stay deterministic.
|
|
1046
812
|
return rows.filter((s): s is Session => s != null && (includeArchived || !s.archived))
|
|
1047
813
|
.sort((a, b) => (a.sortKey ?? a.created) - (b.sortKey ?? b.created) || a.id.localeCompare(b.id))
|
|
1048
814
|
}
|
|
@@ -1055,118 +821,6 @@ function guardSession(id: string, primary: () => Session | null, degraded: () =>
|
|
|
1055
821
|
catch { return existsSync(sessionStoreDir(id)) ? degraded() : null }
|
|
1056
822
|
}
|
|
1057
823
|
|
|
1058
|
-
// @@@ session graph = LIVE monitors, not a stored relationship. An edge A→B means "agent A is RIGHT NOW
|
|
1059
|
-
// running `spex session watch B` (the Monitor tool) over B" — derived from live watch registrations, never a
|
|
1060
|
-
// persisted subscription. When a `spex session watch` process starts it registers here and heartbeats; the edge
|
|
1061
|
-
// exists ONLY while that watch runs (deregistered on exit, dropped on a missed heartbeat). Single owner:
|
|
1062
|
-
// this in-memory map in the SERVER process — the watch process (a separate `spex session watch`) talks to it over
|
|
1063
|
-
// HTTP (POST /api/sessions/edges/watch + …/unwatch). No datastore, no file: a backend restart starts
|
|
1064
|
-
// empty and live watches re-register on their next heartbeat. Kept isolated from the board assembler.
|
|
1065
|
-
// an edge is either a LIVE monitor arrow (A→B = A watches B, directed) or a recorded comms link (A↔B =
|
|
1066
|
-
// they have exchanged `count` direct messages, undirected). The dashboard renders the two kinds apart.
|
|
1067
|
-
export type Edge = { from: string; to: string; kind: 'monitor' | 'comms'; count?: number }
|
|
1068
|
-
|
|
1069
|
-
// @@@ comms log - direct agent talk ([[comms-edge]]), recorded per-worktree. `spex session send` goes
|
|
1070
|
-
// THROUGH the backend (sendText); on a delivered message that carries a sender, the backend appends one
|
|
1071
|
-
// {peer, ts} line to the RECIPIENT's comms log — each message counted exactly once, on the side the backend
|
|
1072
|
-
// already resolved. Persisted (survives a backend restart, unlike the in-memory monitor registrations) and
|
|
1073
|
-
// untracked, in the session's GLOBAL store dir (`comms.ndjson`, keyed by session_id) — it dies with the
|
|
1074
|
-
// session record, matching a graph of LIVE sessions. No sender → not logged. Best-effort: a recording failure
|
|
1075
|
-
// must NEVER fail the delivered message.
|
|
1076
|
-
function commsLog(id: string): string { return sessionArtifactPath(id, 'comms.ndjson') }
|
|
1077
|
-
async function recordComms(toId: string, fromId: string): Promise<void> {
|
|
1078
|
-
if (!fromId || fromId === toId) return
|
|
1079
|
-
try {
|
|
1080
|
-
if (!readRecord(toId)) return
|
|
1081
|
-
appendFileSync(join(storeDir(toId), 'comms.ndjson'), JSON.stringify({ peer: fromId, ts: new Date().toISOString() }) + '\n')
|
|
1082
|
-
} catch { /* a recording failure must not fail the delivered send */ }
|
|
1083
|
-
}
|
|
1084
|
-
// the peers this session has exchanged messages with — one entry per message, newest appended last.
|
|
1085
|
-
function readComms(id: string): string[] {
|
|
1086
|
-
try {
|
|
1087
|
-
const path = commsLog(id)
|
|
1088
|
-
if (!existsSync(path)) return []
|
|
1089
|
-
return readFileSync(path, 'utf8').split('\n').filter(Boolean)
|
|
1090
|
-
.map((l) => { try { return String(JSON.parse(l).peer || '') } catch { return '' } }).filter(Boolean)
|
|
1091
|
-
} catch { return [] }
|
|
1092
|
-
}
|
|
1093
|
-
// keyed by an opaque per-watch token (one per `spex session watch` process), so a single agent may run several
|
|
1094
|
-
// monitors without them clobbering each other. `selectors` is what the watch targets (resolved LIVE at
|
|
1095
|
-
// read time, not frozen here); empty / @all = a GLOBAL watcher. `expires` is the heartbeat backstop.
|
|
1096
|
-
type WatchReg = { watcher: string; selectors: string[]; expires: number }
|
|
1097
|
-
const watches = new Map<string, WatchReg>()
|
|
1098
|
-
const DEFAULT_WATCH_TTL_MS = 15000
|
|
1099
|
-
// register OR heartbeat a live monitor. watcher = the watching agent's OWN session id; ttlMs = how long
|
|
1100
|
-
// this stays live without another beat. Returns false on a bad pair (the route answers 400).
|
|
1101
|
-
export function registerWatch(token: string, watcher: string, selectors: string[], ttlMs = DEFAULT_WATCH_TTL_MS): boolean {
|
|
1102
|
-
if (!token || !watcher) return false
|
|
1103
|
-
watches.set(token, { watcher, selectors: selectors.filter(Boolean), expires: Date.now() + Math.max(1000, ttlMs) })
|
|
1104
|
-
return true
|
|
1105
|
-
}
|
|
1106
|
-
// deregister a watch (its `spex session watch` exited); false if the token wasn't registered.
|
|
1107
|
-
export function deregisterWatch(token: string): boolean { return watches.delete(token) }
|
|
1108
|
-
// the still-live registrations, pruning any whose heartbeat lapsed — the backstop for a watch that died
|
|
1109
|
-
// without a clean unwatch (SIGKILL, a dropped connection, a backend that was down at exit time).
|
|
1110
|
-
function liveWatches(): WatchReg[] {
|
|
1111
|
-
const now = Date.now()
|
|
1112
|
-
const out: WatchReg[] = []
|
|
1113
|
-
for (const [token, reg] of watches) {
|
|
1114
|
-
if (reg.expires <= now) watches.delete(token)
|
|
1115
|
-
else out.push(reg)
|
|
1116
|
-
}
|
|
1117
|
-
return out
|
|
1118
|
-
}
|
|
1119
|
-
// the graph: live sessions as nodes; edges DERIVED from live monitor registrations. Edge A→B = watcher A
|
|
1120
|
-
// is currently watching B. Selectors are resolved LIVE here via selectSessions (the same matcher `spex
|
|
1121
|
-
// ls/watch` use), so a global (@all/empty) watcher links to every CURRENT session — incl. ones launched
|
|
1122
|
-
// after the watch started — and a node/branch selector picks up future matches too. Self-edges and edges
|
|
1123
|
-
// touching a non-live session are dropped; duplicate A→B (two watches over the same pair) collapse to one.
|
|
1124
|
-
export async function sessionGraph(): Promise<{ nodes: Session[]; edges: Edge[] }> {
|
|
1125
|
-
const nodes = await listSessions()
|
|
1126
|
-
const live = new Set(nodes.map((s) => s.id))
|
|
1127
|
-
const edges: Edge[] = []
|
|
1128
|
-
const seen = new Set<string>()
|
|
1129
|
-
for (const reg of liveWatches()) {
|
|
1130
|
-
if (!live.has(reg.watcher)) continue // the watching agent itself is gone
|
|
1131
|
-
for (const t of selectSessions(nodes, reg.selectors)) {
|
|
1132
|
-
if (t.id === reg.watcher) continue
|
|
1133
|
-
const key = `${reg.watcher} ${t.id}`
|
|
1134
|
-
if (seen.has(key)) continue
|
|
1135
|
-
seen.add(key)
|
|
1136
|
-
edges.push({ from: reg.watcher, to: t.id, kind: 'monitor' })
|
|
1137
|
-
}
|
|
1138
|
-
}
|
|
1139
|
-
// comms edges: undirected direct-talk, one per pair, carrying the message count — read from each live
|
|
1140
|
-
// session's per-worktree log and aggregated by sorted pair so A→B and B→A fold into one A↔B count. An
|
|
1141
|
-
// edge to a non-live session is dropped, like the monitor edges.
|
|
1142
|
-
const commsCount = new Map<string, number>()
|
|
1143
|
-
for (const n of nodes) {
|
|
1144
|
-
for (const peer of readComms(n.id)) {
|
|
1145
|
-
if (peer === n.id || !live.has(peer)) continue
|
|
1146
|
-
const key = n.id < peer ? `${n.id}\t${peer}` : `${peer}\t${n.id}`
|
|
1147
|
-
commsCount.set(key, (commsCount.get(key) ?? 0) + 1)
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
for (const [key, count] of commsCount) {
|
|
1151
|
-
const [from, to] = key.split('\t')
|
|
1152
|
-
edges.push({ from, to, kind: 'comms', count })
|
|
1153
|
-
}
|
|
1154
|
-
return { nodes, edges }
|
|
1155
|
-
}
|
|
1156
|
-
|
|
1157
|
-
// @@@ apiBase resolution - WHICH backend a client verb talks to, resolved ONCE per process with the
|
|
1158
|
-
// source kept as a discriminant ([[remote-client]]). The core thesis: a FLAG is the only signal that is
|
|
1159
|
-
// provably deliberate — an env var cannot tell "I exported this on the command" from "I inherited this
|
|
1160
|
-
// from the backend that launched my shell", and that ambiguity is exactly the misroute bug (a shell
|
|
1161
|
-
// carrying project A's SPEXCODE_API_URL silently drives every bare `spex` in project B at A's backend).
|
|
1162
|
-
// The ladder:
|
|
1163
|
-
// 1. explicit `--api <url>` (`--port <n>` = localhost sugar) — always wins, any verb
|
|
1164
|
-
// 2a. WORKER (SPEXCODE_SESSION_ID present): env SPEXCODE_API_URL — the backend-injected lifeline; a
|
|
1165
|
-
// worker's state writes must NEVER gamble on cwd discovery, so the env is not demotable by (2b)
|
|
1166
|
-
// 2b. HUMAN (no session id): the cwd project's RECORDED live backend (`spex serve` writes it, we
|
|
1167
|
-
// /health-probe before trusting — a dead record is ignored, never followed)
|
|
1168
|
-
// 3. the other side as fallback (human with no live record → env; worker with no env → record)
|
|
1169
|
-
// 4. default http://127.0.0.1:$PORT||8787
|
|
1170
824
|
export type ApiBaseSource = 'flag' | 'worker-env' | 'record' | 'env-fallback' | 'default'
|
|
1171
825
|
export type ApiBaseInfo = { url: string; source: ApiBaseSource }
|
|
1172
826
|
const usageError = (msg: string): Error => { const e = new Error(msg); e.name = 'UsageError'; return e }
|
|
@@ -1224,107 +878,27 @@ let apiBaseMemo: Promise<ApiBaseInfo> | null = null
|
|
|
1224
878
|
export const apiBaseInfo = (): Promise<ApiBaseInfo> => (apiBaseMemo ??= resolveApiBase())
|
|
1225
879
|
export const apiBase = async (): Promise<string> => (await apiBaseInfo()).url
|
|
1226
880
|
|
|
1227
|
-
// @@@ watch registration (CLIENT side) - a `spex session watch` process is separate from the server, so it
|
|
1228
|
-
// REPORTS itself to the backend's registration store over HTTP: register+heartbeat while it runs,
|
|
1229
|
-
// deregister on exit (see cli.ts `watch`). All best-effort — if the backend is down the watch still
|
|
1230
|
-
// streams its events; the graph edge just won't appear until a heartbeat lands. Never throws.
|
|
1231
|
-
// the agent's OWN session id from the HARNESS env var — the public name used across cli.ts/sessions.ts.
|
|
1232
|
-
// Single adapter-routed impl lives in layout.ts (`envSessionId`, iterating each adapter's sessionEnvVar);
|
|
1233
|
-
// re-exported here so callers keep one name. Used by `spex session watch` + the agent-typed `spex session …`
|
|
1234
|
-
// declarations; the hooks instead pass `--session <id>` from the payload, so they never depend on this.
|
|
1235
881
|
export const ownSessionId = envSessionId
|
|
1236
882
|
|
|
1237
|
-
// @@@ withSenderHint - bidirectional agent messaging. `spex session send` delivers a prompt to the
|
|
1238
|
-
// recipient; this stamps WHO sent it and HOW to reply as a one-line insert appended to the delivered
|
|
1239
|
-
// message, so the recipient agent CAN reply (or ignore) and the reply rides the SAME send back into the
|
|
1240
|
-
// sender's prompt — a reply channel, no workflow enforcement, just a prompt insert. The sender is the
|
|
1241
|
-
// SENDING agent's OWN session (id from [[dispatch]]'s send-command process via ownSessionId, `label` its
|
|
1242
|
-
// board HEADLINE — sessionHeadline, the same title the recipient reads on the board); its FULL id is stamped
|
|
1243
|
-
// so the reply addresses exactly one session, never a prefix. A human running `send` from a plain shell has
|
|
1244
|
-
// no session id (sender=null) → the bare message, no hint, no loop.
|
|
1245
|
-
// @@@ delimited as a SESSION TITLE - the headline is wrapped `session "<headline>" (<id>)` so the recipient
|
|
1246
|
-
// reads it AS a session title, not as prose bleeding into the message (an un-delimited prompt-derived title
|
|
1247
|
-
// was unrecognisable as a name). A bare-id label (no better name in the chain) needs no quotes.
|
|
1248
883
|
export type MsgSender = { id: string; label: string | null }
|
|
1249
884
|
export function withSenderHint(text: string, sender: MsgSender | null): string {
|
|
1250
885
|
if (!sender) return text
|
|
1251
886
|
const who = sender.label && sender.label !== sender.id ? `session "${sender.label}" (${sender.id})` : `session ${sender.id}`
|
|
1252
887
|
return `${text}\n\n— from ${who}. To reply: spex session send ${sender.id} "<your reply>"`
|
|
1253
888
|
}
|
|
1254
|
-
// @@@ withNoteReplyHint - the HEADLESS TARGET's insert, withSenderHint's sibling: a session with no readable
|
|
1255
|
-
// terminal can return text to its human only through its declaration NOTE ([[session-timeline]]). This
|
|
1256
|
-
// insert tells the agent exactly that, so its next stop carries the complete answer in `--note` instead of
|
|
1257
|
-
// prose that dies in an unseen output stream. The declaration is TRANSPORT, not task work: a raw prompt's
|
|
1258
|
-
// "no tools" / "only print" constraint does not cancel it. composeSessionPrompt is the only production caller
|
|
1259
|
-
// deciding whether it applies; a surface may explicitly request note, but the target adapter owns the
|
|
1260
|
-
// default. The notice declares itself
|
|
1261
|
-
// PER-MESSAGE, and withTerminalReplyHint (below) is its counter-signal: without both, an agent that
|
|
1262
|
-
// note-replied a few times keeps note-replying from context inertia long after the human is back at a
|
|
1263
|
-
// terminal — the sticky-note failure this pair exists to prevent.
|
|
1264
889
|
export const withNoteReplyHint = (text: string): string =>
|
|
1265
890
|
`${text}\n\n— REQUIRED REPLY TRANSPORT (PER-MESSAGE): this terminal-free sender CANNOT see normal assistant/final output. Do not stop after only printing the answer. As your FINAL action, put your COMPLETE reply to this message in the truthful declaration's --note. For a simple answer awaiting the next message, run \`spex session ask --note "<complete reply>"\`; if the true state is done or parked, put the same complete reply in that declaration's --note instead. This declaration command is reply transport, not part of the requested work, and remains REQUIRED even when the message says to use no tools, make no tool calls, or only print/reply. A later message arriving WITHOUT this notice means the sender is back at a terminal and reads your normal output again.`
|
|
1266
|
-
// @@@ withTerminalReplyHint - the BACK-AT-A-TERMINAL counter-insert, appended exactly once at the
|
|
1267
|
-
// note→terminal transition (a human send with no note flag whose previous human send carried one —
|
|
1268
|
-
// [[session-timeline]] lastHumanSendVia). It explicitly countermands the note-reply instruction: telling
|
|
1269
|
-
// the agent once beats hoping it scopes the earlier per-message notice correctly (it demonstrably doesn't —
|
|
1270
|
-
// codex especially). Not repeated on later terminal sends: once the transition send is recorded, the last
|
|
1271
|
-
// human channel is no longer note, so ordinary conversation stays insert-free.
|
|
1272
891
|
export const withTerminalReplyHint = (text: string): string =>
|
|
1273
892
|
`${text}\n\n— sent from a terminal-attached client: the sender now reads your terminal output directly. Reply in your normal conversation output from here on — stop putting replies in declaration --notes (the earlier terminal-free notices no longer apply; a --note can go back to being a short status line).`
|
|
1274
|
-
async function postJSON(path: string, body: unknown): Promise<void> {
|
|
1275
|
-
try {
|
|
1276
|
-
await fetch(`${await apiBase()}${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
|
1277
|
-
} catch { /* best-effort: backend may be down; the next heartbeat / TTL reconciles */ }
|
|
1278
|
-
}
|
|
1279
|
-
export const reportWatch = (token: string, watcher: string, selectors: string[], ttlMs: number): Promise<void> =>
|
|
1280
|
-
postJSON('/api/sessions/edges/watch', { token, watcher, selectors, ttlMs })
|
|
1281
|
-
export const reportUnwatch = (token: string): Promise<void> => postJSON('/api/sessions/edges/unwatch', { token })
|
|
1282
|
-
|
|
1283
|
-
// @@@ isBackendDown - a `client.ts` BackendError surfacing in the watch poll loop (whose session
|
|
1284
|
-
// `source` is the HTTP backend client). Matched by NAME, not `instanceof`, so sessions.ts never imports
|
|
1285
|
-
// client.ts at runtime (client.ts imports apiBase from here — a runtime import back would be a cycle). A
|
|
1286
|
-
// backend-down poll must NOT be swallowed as a transient git/tmux hiccup: watch warns ONCE and keeps
|
|
1287
|
-
// streaming rather than emitting false `closed` events for every session.
|
|
1288
|
-
export const isBackendDown = (e: unknown): boolean => e instanceof Error && e.name === 'BackendError'
|
|
1289
|
-
// @@@ isBackendUnreachable - the TRANSIENT subset of isBackendDown: the fetch itself failed (nothing
|
|
1290
|
-
// listening — ECONNREFUSED / "fetch failed"), which client.ts throws as a BackendError with NO HTTP
|
|
1291
|
-
// `status`. An HTTP BackendError (the backend answered non-2xx) DOES carry a status and is a real error, not
|
|
1292
|
-
// a momentary blip. The distinction matters to `spex session wait`: a supervisor's backgrounded wait must survive
|
|
1293
|
-
// the ~1s window where the supervisor reboots its hot-reloaded child behind the stable port, retrying until
|
|
1294
|
-
// the backend answers again or the deadline hits — never dying on the in-flight fetch that a sibling merge's
|
|
1295
|
-
// restart happens to interrupt. Read via a structural cast (no client.ts import — that would be a cycle).
|
|
1296
|
-
export const isBackendUnreachable = (e: unknown): boolean =>
|
|
1297
|
-
isBackendDown(e) && (e as { status?: number }).status === undefined
|
|
1298
|
-
|
|
1299
|
-
// @@@ slugify - the branch/worktree-safe slug. Keeps ANY unicode letter/number (git refs and the filesystem
|
|
1300
|
-
// take unicode), so a CJK prompt survives as the readable name its author typed instead of being stripped to
|
|
1301
|
-
// nothing — transliteration would buy ASCII at the cost of a dependency and a name nobody wrote. NFC pins one
|
|
1302
|
-
// canonical byte form across IME/OS variants. Non-empty is guaranteed by the 'session' fallback; uniqueness
|
|
1303
|
-
// is the caller's job (the create transaction suffixes the session short-id).
|
|
1304
893
|
export const slugify = (s: string | null) =>
|
|
1305
894
|
(s || 'session').normalize('NFC').replace(/[^\p{L}\p{N}_-]+/gu, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'session'
|
|
1306
895
|
|
|
1307
|
-
// @@@ node + title from the prompt - the spec node a session works on is the FIRST `[[<id>]]` topic
|
|
1308
|
-
// reference in the raw prompt ([[mentions]]: `[[node]]` is a topic, `@` is an actor/session). The node the
|
|
1309
|
-
// user actually left in the prompt is the truth: there is no focused-node/API/function argument that can
|
|
1310
|
-
// grant a binding outside the task text. Changing or deleting the mention changes or removes the binding.
|
|
1311
|
-
// When there is none, the session is node-agnostic and we label it by the first few words of the prompt.
|
|
1312
|
-
// The OPTIONAL leading dot is load-bearing: a node id is its dir basename, so a dot-prefixed config root
|
|
1313
|
-
// (`.plugins`) keeps the dot — without `\.?` here `[[.plugins]]` captures nothing and never resolves to a node.
|
|
1314
|
-
// Token chars are ANY unicode letter/number (slugify's already-made choice): a CJK dir name is a legal node
|
|
1315
|
-
// id, so `[[中文节点]]` must bind the session exactly like an ASCII id — ASCII-only here silently launched
|
|
1316
|
-
// node-agnostic.
|
|
1317
896
|
const MENTION = /\[\[(\.?[\p{L}\p{N}_-]+)\]\]/u
|
|
1318
897
|
export const nodeFromPrompt = (prompt: string): string | null => prompt.match(MENTION)?.[1] ?? null
|
|
1319
898
|
|
|
1320
899
|
type CommandPreset = Pick<ConfigPreset, 'name' | 'body'>
|
|
1321
900
|
type CommandSpec = Pick<SpecLite, 'id' | 'path'>
|
|
1322
901
|
|
|
1323
|
-
// @@@ command invocation - turn the raw `/<preset> [[node]]… free text` into the ONE agent prompt.
|
|
1324
|
-
// This is deliberately server-side: dashboard, phone, CLI, direct API, and the in-process fallback all call
|
|
1325
|
-
// the launch/send boundary, so no client gets its own command interpreter. Launch keeps the RAW prompt for
|
|
1326
|
-
// session identity/history; only the agent payload uses this expansion, preventing a plugin body's own
|
|
1327
|
-
// [[links]] from becoming the session node. With no mention, a preset remains targetless.
|
|
1328
902
|
export function composeCommandPrompt(raw: string, presets: CommandPreset[], specs: CommandSpec[]): string {
|
|
1329
903
|
const match = raw.match(/^\/(\S+)\s*([\s\S]*)$/)
|
|
1330
904
|
if (!match) return raw
|
|
@@ -1378,12 +952,9 @@ export async function composeSessionPrompt(raw: string, target: SessionPromptTar
|
|
|
1378
952
|
const replyVia = opts.replyVia ?? (h.headless ? 'note' : undefined)
|
|
1379
953
|
const text = replyVia === 'note' ? withNoteReplyHint(prompt)
|
|
1380
954
|
: !opts.from && lastHumanSendVia(target.session) === 'note' ? withTerminalReplyHint(prompt) : prompt
|
|
1381
|
-
return { text, ...(replyVia ? { replyVia } : {}) }
|
|
955
|
+
return { text: optionSafe(text), ...(replyVia ? { replyVia } : {}) }
|
|
1382
956
|
}
|
|
1383
|
-
|
|
1384
|
-
// prompt is ANOTHER session's identity, never this one's name. A title/slug wearing it misleads every
|
|
1385
|
-
// board/git surface — and a worker tasked with cleaning that session can match its OWN worktree and delete
|
|
1386
|
-
// it from under itself. Strip both before deriving; whatever prose remains names the session.
|
|
957
|
+
const optionSafe = (text: string) => text.startsWith('-') ? ` ${text}` : text
|
|
1387
958
|
const UUID_TOKEN = /\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b/g
|
|
1388
959
|
const stripIdentityTokens = (s: string) => s.replace(/(^|\s)@[\p{L}\p{N}_-]+/gu, '$1').replace(UUID_TOKEN, ' ')
|
|
1389
960
|
export function titleFromPrompt(prompt: string): string | null {
|
|
@@ -1447,8 +1018,7 @@ export function launchPreflight(rec: SessRec): LaunchBlock | null {
|
|
|
1447
1018
|
|
|
1448
1019
|
// @@@ launch quoting - single-quote a string for a POSIX shell, `'` → `'\''`. Used to nest the whole agent
|
|
1449
1020
|
// invocation inside the birth-registration `sh -c '…'` wrapper without any segment double-expanding.
|
|
1450
|
-
|
|
1451
|
-
export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string, delegateFifo?: string): string {
|
|
1021
|
+
export function launchScript(id: string, tail: string, harness: Harness = HARNESS, cmd?: string): string {
|
|
1452
1022
|
const file = join(storeDir(id), 'launch.sh')
|
|
1453
1023
|
// NO --append-system-prompt / --settings: the contract + hooks are materialized into the worktree at
|
|
1454
1024
|
// createSession ([[harness-delivery]]) and the agent auto-discovers them — the SAME path as a self-launched
|
|
@@ -1461,11 +1031,11 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
1461
1031
|
// agent.pid, then `exec env` REPLACES that sh in place — so the pid persists down the whole command chain
|
|
1462
1032
|
// (claude: env→(reclaude→)claude; codex: env→bash -lc <script> whose last line is `exec codex … resume`), and
|
|
1463
1033
|
// `$$` therefore IS the launched agent's pid. `env` carries the leading `VAR=val` assignments (an env prefix
|
|
1464
|
-
// can't lead an `exec`), and the whole payload is single-quoted for the outer shell (
|
|
1034
|
+
// can't lead an `exec`), and the whole payload is single-quoted for the outer shell (shQuote) so the
|
|
1465
1035
|
// invocation's own single-quoted segments — the codex `$@`/`$tid` script, the prompt — reach sh verbatim,
|
|
1466
1036
|
// parsed exactly ONCE, never double-expanded. Each retry attempt rewrites agent.pid with a fresh `$$`.
|
|
1467
1037
|
const pidPath = join(storeDir(id), 'agent.pid')
|
|
1468
|
-
const born = `sh -c ${
|
|
1038
|
+
const born = `sh -c ${shQuote(`printf %s "$$" > ${shQuote(pidPath)}; exec env ${invocation}`)}`
|
|
1469
1039
|
// Bounded relaunch on a FAST exit: the agent launcher can exit within seconds before the rendezvous socket
|
|
1470
1040
|
// ever appears. That is enough evidence to retry, but not enough evidence to name the cause. Once the agent
|
|
1471
1041
|
// has run past LAUNCH_FAST_FAIL_S it has genuinely started; its eventual (much later) exit is a normal
|
|
@@ -1508,7 +1078,7 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
1508
1078
|
// -t "$TMUX_PANE" names THIS pane explicitly (tmux still resolves the server from $TMUX), so the capture
|
|
1509
1079
|
// can never land on a neighbouring pane; run outside tmux the call fails, nothing matches, and the plain
|
|
1510
1080
|
// bounded retry stands.
|
|
1511
|
-
` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${
|
|
1081
|
+
` if tmux capture-pane -p -S -400 -t "\${TMUX_PANE:-}" 2>/dev/null | sed -n "/$__spex_mark/,\\$p" | grep -Eq ${shQuote(fatal)}; then`,
|
|
1512
1082
|
` printf '[spex launch] attempt %s exited in %ss (rc=%s) - the launcher reported a failure retrying cannot fix (see above); not retrying\\n' "$__spex_try" "$(( SECONDS - __spex_t0 ))" "$__spex_rc" >&2`,
|
|
1513
1083
|
` exit $__spex_rc`,
|
|
1514
1084
|
` fi`,
|
|
@@ -1519,36 +1089,21 @@ export function launchScript(id: string, tail: string, harness: Harness = HARNES
|
|
|
1519
1089
|
`exit $__spex_rc`,
|
|
1520
1090
|
``,
|
|
1521
1091
|
]
|
|
1522
|
-
|
|
1523
|
-
`exec 9<${shq1(delegateFifo)}`,
|
|
1524
|
-
`rm -f ${shq1(delegateFifo)}`,
|
|
1525
|
-
'export SPEXCODE_MAINTENANCE_DELEGATE_FD=9',
|
|
1526
|
-
`export SPEXCODE_MAINTENANCE_SESSION_ID=${shq1(id)}`,
|
|
1527
|
-
] : []
|
|
1528
|
-
writeFileSync(file, [...delegatePreamble, ...launchBody].join('\n'))
|
|
1092
|
+
writeFileSync(file, launchBody.join('\n'))
|
|
1529
1093
|
return file
|
|
1530
1094
|
}
|
|
1531
|
-
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string
|
|
1095
|
+
async function launch(id: string, path: string, tail: string, harness: Harness = HARNESS, cmd?: string): Promise<void> {
|
|
1532
1096
|
// record the transport path THIS runtime hands the agent, before anything reads it (launchScript bakes it
|
|
1533
1097
|
// into the launch env). Same kind of launch-time fact as agent.pid, and the reason a session's socket is
|
|
1534
1098
|
// reachable only from the world it belongs to ([[harness-adapter]] rendezvous socket).
|
|
1535
1099
|
if (harness.ownsRendezvous) stampRvSock(id)
|
|
1536
1100
|
await tmux(['new-session', '-d', '-s', id, '-x', String(COLS), '-y', String(ROWS), '-c', path])
|
|
1537
|
-
await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd
|
|
1101
|
+
await tmux(['send-keys', '-t', id, '-l', '--', `bash ${launchScript(id, tail, harness, cmd)}`])
|
|
1538
1102
|
await tmux(['send-keys', '-t', id, 'Enter'])
|
|
1539
1103
|
launchedAt.set(id, Date.now()) // stamp the boot window so reconcile reads 'starting', not 'offline', until the socket is up
|
|
1540
1104
|
}
|
|
1541
1105
|
|
|
1542
1106
|
|
|
1543
|
-
// @@@ concurrency cap + queue - keep at most maxActive() agents AUTONOMOUSLY PROGRESSING at once. A slot is
|
|
1544
|
-
// COMPUTE pressure, so only an agent actually consuming it holds one: genuinely live (tmux window + rendezvous
|
|
1545
|
-
// socket present) AND either churning (`working`) or paused-to-self-resume (`parked`). Every state that is
|
|
1546
|
-
// WAITING ON THE HUMAN frees its slot — `idle` (stopped at its prompt), `asking` (asked a question), and the
|
|
1547
|
-
// proposal states (review/done/close-pending) — exactly as `offline`/`queued` do. Those agents burn no
|
|
1548
|
-
// compute, so they must NEVER block a fresh launch: the old rule counted them, so a pile of "waiting on you"
|
|
1549
|
-
// sessions wedged the queue while the box sat near-idle (the reported blockage). Liveness is still checked
|
|
1550
|
-
// directly (the socket truth reconcile uses), so an authored `parked` whose claude has since died does NOT
|
|
1551
|
-
// pin a slot. The cap throttles concurrent COMPUTE; everything waiting-on-you waits cheap as a live pane.
|
|
1552
1107
|
const OCCUPIES_SLOT = new Set<DisplayStatus>(['working', 'parked', 'starting']) // starting's boot window is also held via `launching`
|
|
1553
1108
|
function isOccupying(s: Session, snap: LiveSnap): boolean {
|
|
1554
1109
|
if (!OCCUPIES_SLOT.has(s.status)) return false // waiting-on-human / proposed / queued / dead → free
|
|
@@ -1606,13 +1161,17 @@ async function startQueuedUnlocked(id: string): Promise<boolean> {
|
|
|
1606
1161
|
launching.add(id) // hold the slot across the boot window BEFORE we launch, so a concurrent count can't race us
|
|
1607
1162
|
const h = harnessById(wt.rec.harness || defaultHarness.id) // launch THIS session's chosen harness (also drives waitForReady below)
|
|
1608
1163
|
try {
|
|
1609
|
-
const sq =
|
|
1164
|
+
const sq = shQuote(launchPrompt)
|
|
1610
1165
|
await launch(id, wt.path, `${h.sessionIdArg(id)} ${sq}`.trim(), h, launcherCmd(wt.rec))
|
|
1611
1166
|
} catch {
|
|
1612
1167
|
launching.delete(id)
|
|
1613
1168
|
return false // launch failed → stays `queued`, retried on the next drain tick
|
|
1614
1169
|
}
|
|
1615
|
-
|
|
1170
|
+
// the note this record may carry is the QUEUED state's word (a launch-blocker message stamped above); the
|
|
1171
|
+
// launch just succeeded, so it is spent. Clearing it with the transition is what keeps "a stored note
|
|
1172
|
+
// belongs to the state currently declared" true for every writer — the invariant [[session-label]]'s
|
|
1173
|
+
// headline precedence stands on.
|
|
1174
|
+
writeRecord({ ...wt.rec, status: 'active', proposal: null, note: null, launchOwner: null })
|
|
1616
1175
|
removeLaunchFile(id) // consumed
|
|
1617
1176
|
// release the boot-window hold once the socket is up (then isOccupying takes over) or after the bounded
|
|
1618
1177
|
// wait — so a launch that never booted reads offline and the drainer reclaims the slot instead of pinning it.
|
|
@@ -1621,11 +1180,6 @@ async function startQueuedUnlocked(id: string): Promise<boolean> {
|
|
|
1621
1180
|
}
|
|
1622
1181
|
const startQueued = (id: string): Promise<boolean> => withSessionTransition(id, () => withRecordLock(id, () => startQueuedUnlocked(id)))
|
|
1623
1182
|
|
|
1624
|
-
// @@@ drainQueue - start as many `queued` sessions as there are free slots, oldest first. Idempotent and
|
|
1625
|
-
// re-entrancy-guarded; safe to call on every slot-freeing event (session creation / close / propose) AND on a
|
|
1626
|
-
// periodic tick (superviseQueue) — the periodic tick is what catches the AGENT-authored transitions
|
|
1627
|
-
// (done/parked written by a hook SUBPROCESS, which can't reach this server's queue). Re-lists each iteration
|
|
1628
|
-
// so a freshly launched session (held in `launching`) counts immediately and we never exceed the cap.
|
|
1629
1183
|
async function drainQueueUnlocked(): Promise<void> {
|
|
1630
1184
|
if (draining) return
|
|
1631
1185
|
draining = true
|
|
@@ -1651,20 +1205,13 @@ async function drainQueueUnlocked(): Promise<void> {
|
|
|
1651
1205
|
}
|
|
1652
1206
|
} finally { draining = false }
|
|
1653
1207
|
}
|
|
1654
|
-
export const drainQueue = (): Promise<void> =>
|
|
1208
|
+
export const drainQueue = (): Promise<void> => drainQueueUnlocked()
|
|
1655
1209
|
const requestQueueDrain = (): void => {
|
|
1656
1210
|
void drainQueue().catch((error) => {
|
|
1657
|
-
// An exact maintenance stop deliberately frees no unrelated queue work while admission is closed.
|
|
1658
|
-
if (error instanceof SessionMaintenanceError && error.code === 'maintenance_active') return
|
|
1659
1211
|
console.error(`spex: queue drain failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
1660
1212
|
})
|
|
1661
1213
|
}
|
|
1662
1214
|
|
|
1663
|
-
// @@@ superviseQueue - the periodic drainer. Started once at serve(). The explicit drainQueue() calls on
|
|
1664
|
-
// session creation/close/propose cover the slot-freeing events the SERVER handles, but an agent proposing done or
|
|
1665
|
-
// going parked writes its global session.json record from a hook subprocess the server never sees, and a crash just makes a
|
|
1666
|
-
// socket vanish — so a timer is what turns those into freed slots. Cheap: one worktree+tmux snapshot per tick,
|
|
1667
|
-
// and a no-op when nothing is queued. Idempotent (guarded), so a second call is harmless.
|
|
1668
1215
|
let supervisingQueue = false
|
|
1669
1216
|
export function superviseQueue(intervalMs = 3000): void {
|
|
1670
1217
|
if (supervisingQueue) return
|
|
@@ -1776,27 +1323,16 @@ export function superviseTurnFailures(intervalMs = 1000): void {
|
|
|
1776
1323
|
tick()
|
|
1777
1324
|
}
|
|
1778
1325
|
|
|
1779
|
-
// @@@ assertProjectMatch - a WRITE is PROJECT-BOUND, but routing is by URL. A mutating verb's intent is
|
|
1780
|
-
// "act on the project my cwd is in", yet the resolved base is a pure URL carrying no project identity —
|
|
1781
|
-
// the backend it answers acts on ITS OWN mainRoot, so a stale inherited SPEXCODE_API_URL (pointing at
|
|
1782
|
-
// another repo's backend) silently lands the write in the WRONG repo. Read/control-READS deliberately
|
|
1783
|
-
// point anywhere (viewer-points-anywhere, see remote-client); every MUTATING verb (new/merge/send/close/
|
|
1784
|
-
// rename/input/resume/stop) is bound to the caller's project. So before writing, compare the caller's
|
|
1785
|
-
// repo root to the backend's served root and FAIL LOUD on a provable, same-host mismatch — never a silent
|
|
1786
|
-
// misroute. An explicit `--api`/`--port` flag SKIPS the guard: the flag is the one provably-deliberate
|
|
1787
|
-
// cross-project signal (that's the whole flag-beats-env thesis). The guard fires only on a positive
|
|
1788
|
-
// mismatch: no local repo, an unreachable backend, or a backend root that isn't a resolvable local path
|
|
1789
|
-
// (a genuinely remote backend) all fall through to allow, so legit remote drive stays untouched.
|
|
1790
1326
|
type BackendSettings = { layout?: { main?: string } }
|
|
1791
|
-
|
|
1327
|
+
type BackendInstance = { root?: unknown }
|
|
1328
|
+
function assertProjectRootMatch(verb: string, target: ApiBaseInfo, servedRoot: string | null): void {
|
|
1792
1329
|
const { url, source } = target
|
|
1793
1330
|
if (source === 'flag') return // explicitly routed — the caller named the target
|
|
1794
1331
|
let localMain: string
|
|
1795
1332
|
try { localMain = realpathSync(mainRoot()) } catch { return } // caller not in a repo → can't prove a mismatch
|
|
1796
|
-
|
|
1797
|
-
if (!served || !isAbsolute(served)) return // unknown / config-aliased root → don't risk a false refusal
|
|
1333
|
+
if (!servedRoot || !isAbsolute(servedRoot)) return // unknown / config-aliased root → don't risk a false refusal
|
|
1798
1334
|
let backendMain: string
|
|
1799
|
-
try { backendMain = realpathSync(
|
|
1335
|
+
try { backendMain = realpathSync(servedRoot) } catch { return } // backend root not a local path → a remote backend, allow
|
|
1800
1336
|
if (backendMain !== localMain) {
|
|
1801
1337
|
const e = new Error(
|
|
1802
1338
|
`${verb}: refusing WRITE — cwd is in ${localMain} but the backend at ${url} serves ${backendMain}.\n` +
|
|
@@ -1806,6 +1342,16 @@ function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings:
|
|
|
1806
1342
|
throw e
|
|
1807
1343
|
}
|
|
1808
1344
|
}
|
|
1345
|
+
function assertProjectSettingsMatch(verb: string, target: ApiBaseInfo, settings: BackendSettings | null): void {
|
|
1346
|
+
assertProjectRootMatch(verb, target, settings?.layout?.main ?? null)
|
|
1347
|
+
}
|
|
1348
|
+
function assertProjectInstanceMatch(verb: string, target: ApiBaseInfo, instance: BackendInstance | null): void {
|
|
1349
|
+
const root = instance?.root
|
|
1350
|
+
if (typeof root !== 'string' || !isAbsolute(root)) return
|
|
1351
|
+
let servedMain: string
|
|
1352
|
+
try { servedMain = mainRoot(root) } catch { return }
|
|
1353
|
+
assertProjectRootMatch(verb, target, servedMain)
|
|
1354
|
+
}
|
|
1809
1355
|
export async function assertProjectMatch(verb: string): Promise<void> {
|
|
1810
1356
|
const target = await apiBaseInfo()
|
|
1811
1357
|
if (target.source === 'flag') return
|
|
@@ -1841,7 +1387,6 @@ type SessionCreateRequestOptions = {
|
|
|
1841
1387
|
requestKey?: string
|
|
1842
1388
|
signal?: AbortSignal
|
|
1843
1389
|
timeoutMs?: number
|
|
1844
|
-
operation?: 'create' | 'fallback-create'
|
|
1845
1390
|
}
|
|
1846
1391
|
export type SessionCreateRequestResult =
|
|
1847
1392
|
| { status: 201; session: Session }
|
|
@@ -1909,37 +1454,24 @@ export async function sessionCreateRequest(body: unknown, options: SessionCreate
|
|
|
1909
1454
|
timer.unref?.()
|
|
1910
1455
|
traceSessionCreate(id, requestDigest, 'request', 'start')
|
|
1911
1456
|
try {
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
?
|
|
1921
|
-
:
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
|
|
1925
|
-
}
|
|
1926
|
-
})
|
|
1457
|
+
try {
|
|
1458
|
+
const session = await prepareSession(prompt, parent, launcher, { id, requestDigest, payloadHash, signal: controller.signal })
|
|
1459
|
+
traceSessionCreate(id, requestDigest, 'request', 'finish')
|
|
1460
|
+
return { status: 201, session }
|
|
1461
|
+
} catch (error) {
|
|
1462
|
+
const failure = error instanceof SessionCreateError
|
|
1463
|
+
? error
|
|
1464
|
+
: controller.signal.aborted
|
|
1465
|
+
? createAbortError(controller.signal, 'request')
|
|
1466
|
+
: new SessionCreateError('session_create_failed', 'request', String((error as Error).message || error), 400)
|
|
1467
|
+
return { status: failure.status, error: failure.message, code: failure.code, phase: failure.phase }
|
|
1468
|
+
}
|
|
1927
1469
|
} finally {
|
|
1928
1470
|
clearTimeout(timer)
|
|
1929
1471
|
options.signal?.removeEventListener('abort', cancel)
|
|
1930
1472
|
}
|
|
1931
1473
|
}
|
|
1932
1474
|
|
|
1933
|
-
// @@@ createSession (dispatch via backend) - `spex session new` must launch the worker in the
|
|
1934
|
-
// BACKEND's process, not the caller's, because the backend is the single owner of the concurrency cap and the
|
|
1935
|
-
// launch QUEUE (drainQueue). An in-process launch by an agent that runs `spex session new` (e.g. a supervisor) would
|
|
1936
|
-
// bypass that queue and the maxActive gate. (The launch COMMAND is not a process-env concern anymore — it
|
|
1937
|
-
// comes from the session's pinned launcher, resolved from project config [[launcher-select]], identical in
|
|
1938
|
-
// either process.) So the CLI POSTs to the running backend whenever one answers. Only an explicit
|
|
1939
|
-
// ECONNREFUSED proves there is no owner on the target and permits the legacy in-process fallback. A timeout,
|
|
1940
|
-
// reset, DNS failure, or other ambiguous transport result may hide an admitted request, so it fails loud and
|
|
1941
|
-
// never starts a second writer. The backend POST, mention dispatch, and fallback all enter through
|
|
1942
|
-
// sessionCreateRequest; the private preparation half cannot be called without its bounded context.
|
|
1943
1475
|
function isExplicitConnectionRefused(error: unknown): boolean {
|
|
1944
1476
|
if (!error || typeof error !== 'object') return false
|
|
1945
1477
|
if ((error as NodeJS.ErrnoException).code === 'ECONNREFUSED') return true
|
|
@@ -1951,31 +1483,28 @@ async function probeSessionCreateAuthority(target: ApiBaseInfo): Promise<boolean
|
|
|
1951
1483
|
const controller = new AbortController()
|
|
1952
1484
|
const timer = setTimeout(() => controller.abort(), 1500)
|
|
1953
1485
|
timer.unref?.()
|
|
1486
|
+
let response: Response
|
|
1954
1487
|
try {
|
|
1955
|
-
|
|
1956
|
-
let settings: BackendSettings | null = null
|
|
1957
|
-
if (response.ok) {
|
|
1958
|
-
try { settings = await response.json() as BackendSettings }
|
|
1959
|
-
catch (error) { if (controller.signal.aborted) throw error }
|
|
1960
|
-
}
|
|
1961
|
-
assertProjectSettingsMatch('spex session new', target, settings)
|
|
1962
|
-
return false
|
|
1488
|
+
response = await fetch(`${target.url}/api/instance`, { signal: controller.signal })
|
|
1963
1489
|
} catch (error) {
|
|
1490
|
+
clearTimeout(timer)
|
|
1964
1491
|
if (isExplicitConnectionRefused(error)) return true
|
|
1965
1492
|
const failed = new Error(`backend availability is indeterminate at ${target.url}; refusing in-process session creation (${error instanceof Error ? error.message : error})`)
|
|
1966
1493
|
failed.name = 'BackendError'
|
|
1967
1494
|
Object.assign(failed, { code: 'backend_availability_indeterminate', cause: error })
|
|
1968
1495
|
throw failed
|
|
1496
|
+
}
|
|
1497
|
+
try {
|
|
1498
|
+
let instance: BackendInstance | null = null
|
|
1499
|
+
if (response.ok) {
|
|
1500
|
+
try { instance = await response.json() as BackendInstance }
|
|
1501
|
+
catch { /* an HTTP response already established backend authority */ }
|
|
1502
|
+
}
|
|
1503
|
+
assertProjectInstanceMatch('spex session new', target, instance)
|
|
1504
|
+
return false
|
|
1969
1505
|
} finally { clearTimeout(timer) }
|
|
1970
1506
|
}
|
|
1971
1507
|
export async function createSession(prompt: string, launcher?: string): Promise<Session> {
|
|
1972
|
-
if (maintenanceBrokerDescriptors()) {
|
|
1973
|
-
throw new SessionMaintenanceError('maintenance_capability_missing', 'maintenance operator broker admits only its exact stop/resume plan', { operation: 'create' })
|
|
1974
|
-
}
|
|
1975
|
-
// @@@ parent = the CALLER's own session ([[session-nesting]]). Resolve it HERE, in the caller's process,
|
|
1976
|
-
// via the SAME ownSessionId env read [[agent-reply-channel]] uses for its sender hint — NOT inside the
|
|
1977
|
-
// backend, whose process env carries no acting session id. An agent that runs `spex session new` stamps its own id;
|
|
1978
|
-
// a human in a plain shell has none → null → the new session is top-level (no phantom nesting).
|
|
1979
1508
|
const parent = ownSessionId()
|
|
1980
1509
|
const requestKey = randomUUID()
|
|
1981
1510
|
const target = await apiBaseInfo()
|
|
@@ -1983,7 +1512,7 @@ export async function createSession(prompt: string, launcher?: string): Promise<
|
|
|
1983
1512
|
const refused = await probeSessionCreateAuthority(target)
|
|
1984
1513
|
if (refused) {
|
|
1985
1514
|
console.error('spex: no backend reachable — launching in-process (caller env owns auth, no concurrency cap)')
|
|
1986
|
-
const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey
|
|
1515
|
+
const fallback = await sessionCreateRequest({ prompt, parent, launcher }, { requestKey })
|
|
1987
1516
|
if (fallback.status === 201) return fallback.session
|
|
1988
1517
|
const error = new Error(`${fallback.code || 'session_create_failed'}: ${fallback.error}`)
|
|
1989
1518
|
error.name = 'BackendError'
|
|
@@ -2016,14 +1545,6 @@ export async function createSession(prompt: string, launcher?: string): Promise<
|
|
|
2016
1545
|
return await res.json() as Session
|
|
2017
1546
|
}
|
|
2018
1547
|
|
|
2019
|
-
// @@@ spawnerClause - where the SPAWNER works, told to the child ([[spawner-pointer]]). A child's worktree is
|
|
2020
|
-
// branched off the BASE branch, never off its spawner, so everything that session has in flight — a spec node
|
|
2021
|
-
// it just created, an edit it hasn't landed — is absent from the child's tree AND from the spec index the
|
|
2022
|
-
// pointer above resolves against (that index reads the backend's own checkout). Teaching the fork or the
|
|
2023
|
-
// landing about nesting would cost a second base per session and would carry the spawner's unreviewed commits
|
|
2024
|
-
// into whatever the child merges into; naming the spawner's worktree costs one line and lets the agent decide.
|
|
2025
|
-
// A POINTER, never a body — same family rule as [[spec-pointer]] — and fail-quiet by absence: no parent, or a
|
|
2026
|
-
// parent record without a worktree, appends nothing.
|
|
2027
1548
|
export function spawnerClause(p: SessRec | null): string {
|
|
2028
1549
|
if (!p?.worktreePath) return ''
|
|
2029
1550
|
const who = p.name || p.title
|
|
@@ -2252,14 +1773,6 @@ async function proveSessionCandidate(path: string, branch: string, signal: Abort
|
|
|
2252
1773
|
return null
|
|
2253
1774
|
}
|
|
2254
1775
|
|
|
2255
|
-
// @@@ prepareSession - private required-context half of the bounded create owner. It prepares one durable
|
|
2256
|
-
// worktree (branch node/<slug> off main) + global session.json record. The agent does NOT
|
|
2257
|
-
// launch inline any more: the worktree is prepared and parked as `queued`, then drainQueue() launches it
|
|
2258
|
-
// immediately if we're under the concurrency cap, else it waits its turn. Backs both the dashboard POST and
|
|
2259
|
-
// `spex session new`. Creating or deleting a spec node is NOT a server op — it is prompt-driven work the
|
|
2260
|
-
// launched agent does itself (the composer's nn/dd chords just prefill a plain instruction). So the server
|
|
2261
|
-
// only ever launches a session; it never mutates the spec tree ([[mentions]]: the issue store is the sole
|
|
2262
|
-
// programmatic surface, every other surface is prompt only).
|
|
2263
1776
|
async function prepareSession(prompt: string, parent: string | null, launcher: string | undefined, context: SessionCreateContext): Promise<Session> {
|
|
2264
1777
|
const { id, requestDigest, payloadHash, signal } = context
|
|
2265
1778
|
let phase: SessionCreatePhase = 'creation-lock'
|
|
@@ -2445,14 +1958,6 @@ async function prepareSession(prompt: string, parent: string | null, launcher: s
|
|
|
2445
1958
|
}
|
|
2446
1959
|
}
|
|
2447
1960
|
|
|
2448
|
-
// @@@ bootstrapMaterialize - the creation-time materialize is BOOTSTRAP, not best-effort: it is what writes
|
|
2449
|
-
// the worktree's .claude/.codex shims (the settings.json hook wiring) in the first place, and every
|
|
2450
|
-
// lifecycle dispatch RIDES ON those hooks — so when this materialize fails, no hook ever fires,
|
|
2451
|
-
// and the worker comes up ungoverned (no contract block, no stop-gate) with nothing saying so. Fail loud
|
|
2452
|
-
// instead: log the cause + worktree, and stamp the failure on the record's `note` so the board/watch surface
|
|
2453
|
-
// it. The launch still proceeds — a visibly degraded worker the human can close + re-dispatch beats a refused
|
|
2454
|
-
// launch, and status stays agent-authored ([[state]]): we stamp the note, never an inferred `error` state.
|
|
2455
|
-
// `doMaterialize` is injectable only so tests can simulate the failure.
|
|
2456
1961
|
export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string) => unknown = materialize): void {
|
|
2457
1962
|
try {
|
|
2458
1963
|
doMaterialize(rec.worktreePath)
|
|
@@ -2461,15 +1966,6 @@ export function bootstrapMaterialize(rec: SessRec, doMaterialize: (proj: string)
|
|
|
2461
1966
|
}
|
|
2462
1967
|
}
|
|
2463
1968
|
|
|
2464
|
-
// @@@ waitForReady - after a launch/relaunch, the agent needs SEVERAL SECONDS to come up; launch() only TYPES
|
|
2465
|
-
// the start line via send-keys and returns immediately, so the agent's online-signal does not exist yet on
|
|
2466
|
-
// return. Poll the ADAPTER's liveness ([[harness-adapter]]) at a small interval up to a bounded timeout so the
|
|
2467
|
-
// agent counts as "ready" only once it is genuinely online — claude: its rendezvous socket up; codex: its
|
|
2468
|
-
// project app-server socket up AND native thread id captured — then a follow-on dispatch (merge / send) lands
|
|
2469
|
-
// in a LIVE agent instead of racing the boot and failing loud on a session that is actually recovering.
|
|
2470
|
-
// BOUNDED + fail-loud preserved: a
|
|
2471
|
-
// genuinely dead/unrecoverable agent never goes online, so after the timeout we return and the caller's own
|
|
2472
|
-
// deliver() fails loud exactly as before — this only closes the startup race, it adds no fallback.
|
|
2473
1969
|
const SOCKET_READY_TIMEOUT_MS = 30000 // spans launchScript's bounded fast-fail relaunch window, so
|
|
2474
1970
|
// waitForReady (slot-hold + resume) waits through a daemon-race retry
|
|
2475
1971
|
// instead of returning before a recovering socket
|
|
@@ -2501,68 +1997,9 @@ async function waitForReady(id: string, harness: Harness, pending?: SessRec, tim
|
|
|
2501
1997
|
}
|
|
2502
1998
|
}
|
|
2503
1999
|
|
|
2504
|
-
|
|
2505
|
-
// • RESUME GUARD ([[state]]): a relaunch KILLS the running agent (`kill-session` + fresh window), so it is a
|
|
2506
|
-
// data-loss operation the moment the agent is actually ALIVE — the incident's kill-shot was restore-on-alive
|
|
2507
|
-
// (the board LIED offline, the human relaunched, live workers died mid-work). So resume re-derives the
|
|
2508
|
-
// agent's liveness FRESH and, when the caller is guarding (the human relaunch panel / `spex session resume`,
|
|
2509
|
-
// `guard` default true), REFUSES LOUD rather than relaunch a live agent — you steer a live agent by
|
|
2510
|
-
// MESSAGING it, not by restoring it. Death must be PROVEN: an `unknown` probe (tmux timed out under load —
|
|
2511
|
-
// the exact condition that started the incident) also refuses, since a live worker can't be ruled out. A
|
|
2512
|
-
// `force` escape exists for a genuinely-wedged-but-alive process. The merge dispatch passes `guard:false`:
|
|
2513
|
-
// it only needs a LIVE agent to send the merge prompt to, so an already-online agent is a satisfied no-op
|
|
2514
|
-
// (never a refusal), and only a CONFIRMED-offline one is relaunched.
|
|
2515
|
-
// • liveness/relaunch: relaunch only when the agent is CONFIRMED `offline` (or `force`) — never on `online`
|
|
2516
|
-
// (alive), `starting` (booting), or `unknown` (unproven). We drop any stale pane and launch a fresh window
|
|
2517
|
-
// through the adapter's resumeArg — claude `--resume <id>` (the SAME conversation), codex `resume
|
|
2518
|
-
// <thread-id>` once captured, else a fresh TUI — then WAIT (waitForReady) so a caller that dispatches
|
|
2519
|
-
// immediately after (mergeSession's merge) addresses a LIVE agent, not a racing boot.
|
|
2520
|
-
// • lifecycle: the SAME active-only guard markIdle uses — a resumed agent that was WORKING (`active`) is now
|
|
2521
|
-
// just sitting at its prompt → `idle`; EVERY deliberate declaration survives untouched (`awaiting` + its
|
|
2522
|
-
// proposal, `asking`, `parked`, `error`, `queued`). resume does NOT touch the `proposal` — resuming a
|
|
2523
|
-
// session that is proposing a merge must NOT silently withdraw it. Only applied when we actually relaunch;
|
|
2524
|
-
// a refusal leaves the record wholly untouched.
|
|
2525
|
-
// Fail-loud is unchanged: if the agent never comes online, the later deliver() fails loud.
|
|
2526
|
-
type ResumeOptions = { force?: boolean; guard?: boolean; authorization?: Authorization }
|
|
2527
|
-
type ResumeExecutionOptions = ResumeOptions & { ticket?: MaintenanceTicket }
|
|
2528
|
-
|
|
2529
|
-
async function delegatedSpawnTransfer(id: string, ticket: MaintenanceTicket): Promise<{
|
|
2530
|
-
fifo: string
|
|
2531
|
-
done: Promise<void>
|
|
2532
|
-
close(): void
|
|
2533
|
-
}> {
|
|
2534
|
-
const fifo = join(storeDir(id), `.maintenance-delegate-${randomUUID()}.fifo`)
|
|
2535
|
-
await pexec('mkfifo', ['-m', '600', fifo])
|
|
2536
|
-
const bearer = ticket.delegateSharedSpawn(id)
|
|
2537
|
-
const writer = spawn('sh', ['-c', 'cat > "$1"', 'spex-maintenance-delegate', fifo], {
|
|
2538
|
-
stdio: ['pipe', 'ignore', 'pipe'],
|
|
2539
|
-
})
|
|
2540
|
-
let stderr = ''
|
|
2541
|
-
writer.stderr.setEncoding('utf8').on('data', (chunk) => { stderr += chunk })
|
|
2542
|
-
writer.stdin.on('error', () => {})
|
|
2543
|
-
writer.stdin.end(bearer)
|
|
2544
|
-
let settled = false
|
|
2545
|
-
const done = new Promise<void>((resolve, reject) => {
|
|
2546
|
-
writer.once('error', (error) => { settled = true; reject(error) })
|
|
2547
|
-
writer.once('close', (code, signal) => {
|
|
2548
|
-
settled = true
|
|
2549
|
-
if (code === 0) resolve()
|
|
2550
|
-
else reject(new SessionMaintenanceError('maintenance_delegate_invalid',
|
|
2551
|
-
`shared-spawn delegate transfer failed (${signal || code}${stderr.trim() ? `: ${stderr.trim()}` : ''})`,
|
|
2552
|
-
{ operation: 'shared-spawn', sessionId: id }))
|
|
2553
|
-
})
|
|
2554
|
-
})
|
|
2555
|
-
return {
|
|
2556
|
-
fifo,
|
|
2557
|
-
done,
|
|
2558
|
-
close() {
|
|
2559
|
-
if (!settled) writer.kill('SIGTERM')
|
|
2560
|
-
rmSync(fifo, { force: true })
|
|
2561
|
-
},
|
|
2562
|
-
}
|
|
2563
|
-
}
|
|
2000
|
+
type ResumeOptions = { force?: boolean; guard?: boolean }
|
|
2564
2001
|
|
|
2565
|
-
async function resumeSessionUnlocked(id: string, opts:
|
|
2002
|
+
async function resumeSessionUnlocked(id: string, opts: ResumeOptions = {}): Promise<{ ok: boolean; error?: string; refused?: boolean; info?: string }> {
|
|
2566
2003
|
const { force = false, guard = true } = opts
|
|
2567
2004
|
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
2568
2005
|
try { wt = await findWorktree(id) }
|
|
@@ -2629,13 +2066,7 @@ async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions =
|
|
|
2629
2066
|
const resumed: SessRec = { ...current, archived: false, coldProof: null, status: current.status === 'active' ? 'idle' : current.status, stopped: false }
|
|
2630
2067
|
if (force || lv === 'offline') {
|
|
2631
2068
|
await tmuxOk(['kill-session', '-t', id]) // drop a dead/offline pane (or a force-killed live one)
|
|
2632
|
-
|
|
2633
|
-
? await delegatedSpawnTransfer(id, opts.ticket)
|
|
2634
|
-
: null
|
|
2635
|
-
try {
|
|
2636
|
-
await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec), transfer?.fifo)
|
|
2637
|
-
if (transfer) await transfer.done
|
|
2638
|
-
} finally { transfer?.close() }
|
|
2069
|
+
await launch(id, wt.path, h.resumeArg(wt.rec).trim(), h, launcherCmd(wt.rec))
|
|
2639
2070
|
let readiness: HarnessLaunchReadinessFence | null = null
|
|
2640
2071
|
let readinessError = ''
|
|
2641
2072
|
try { readiness = await waitForReady(id, h, resumed) }
|
|
@@ -2680,18 +2111,12 @@ async function resumeSessionUnlocked(id: string, opts: ResumeExecutionOptions =
|
|
|
2680
2111
|
return { ok: true }
|
|
2681
2112
|
}
|
|
2682
2113
|
export const resumeSession = (id: string, opts: ResumeOptions = {}) =>
|
|
2683
|
-
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
// @@@ agent-authored state - the agent (forced by gates at boundaries) writes its OWN state; it is the
|
|
2687
|
-
// authority on what a stop MEANS (awaiting human vs parked on a background task). External hooks only know
|
|
2688
|
-
// SOMETHING changed, not the transition, so they force a write, never infer. The session it writes is resolved
|
|
2689
|
-
// by id: `sessionId` (the hooks pass `--session <id>` from the payload) wins, else ownSessionId() (the env var
|
|
2690
|
-
// the agent's own `spex session …` carries). Unknown id / no record → false (the route/CLI reports it).
|
|
2114
|
+
withSessionTransition(id, () => withRecordLock(id, () => resumeSessionUnlocked(id, opts)))
|
|
2115
|
+
|
|
2691
2116
|
export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?: string; sessionId?: string } = {}): boolean {
|
|
2692
2117
|
const id = opts.sessionId || ownSessionId()
|
|
2693
2118
|
if (!id) return false
|
|
2694
|
-
return
|
|
2119
|
+
return withRecordLockSync(id, () => {
|
|
2695
2120
|
const rec = readLiveRecord(id)
|
|
2696
2121
|
if (!rec) return false
|
|
2697
2122
|
writeRecord({
|
|
@@ -2700,21 +2125,18 @@ export function markState(status: Lifecycle, opts: { proposal?: Proposal; note?:
|
|
|
2700
2125
|
note: opts.note ?? null,
|
|
2701
2126
|
})
|
|
2702
2127
|
return true
|
|
2703
|
-
})
|
|
2128
|
+
})
|
|
2704
2129
|
}
|
|
2705
2130
|
export const markDone = (proposal: Proposal = 'nothing', sessionId?: string, note?: string) => markState('awaiting', { proposal, note, sessionId })
|
|
2706
2131
|
export const markError = (sessionId?: string) => markState('error', { sessionId })
|
|
2707
|
-
// @@@ harness turn failure - native adapter failures are external runtime facts that must become visible on
|
|
2708
|
-
// the durable board. Compare-and-set only an undeclared active record, so a declaration that landed before a
|
|
2709
|
-
// late process close or app-server completion remains authoritative.
|
|
2710
2132
|
export function markTurnFailure(sessionId: string | undefined, note: string): boolean {
|
|
2711
2133
|
if (!sessionId) return false
|
|
2712
|
-
return
|
|
2134
|
+
return withRecordLockSync(sessionId, () => {
|
|
2713
2135
|
const rec = readLiveRecord(sessionId)
|
|
2714
2136
|
if (!rec || rec.status !== 'active' || rec.stopped || rec.archived) return false
|
|
2715
2137
|
writeRecord({ ...rec, status: 'error', proposal: null, note })
|
|
2716
2138
|
return true
|
|
2717
|
-
})
|
|
2139
|
+
})
|
|
2718
2140
|
}
|
|
2719
2141
|
export function markHeadlessTurnFailure(sessionId: string, harness: string, exitCode: string): boolean {
|
|
2720
2142
|
if (exitCode === '0') return false
|
|
@@ -2724,49 +2146,54 @@ export function markHeadlessTurnFailure(sessionId: string, harness: string, exit
|
|
|
2724
2146
|
export function markHarnessSessionId(sessionId: string | undefined, harnessSessionId: string | undefined): boolean {
|
|
2725
2147
|
const id = sessionId || ownSessionId()
|
|
2726
2148
|
if (!id || !harnessSessionId) return false
|
|
2727
|
-
return
|
|
2149
|
+
return withRecordLockSync(id, () => {
|
|
2728
2150
|
const rec = readLiveRecord(id)
|
|
2729
2151
|
if (!rec) return false
|
|
2730
|
-
|
|
2152
|
+
if (rec.harnessSessionId && rec.harnessSessionId !== harnessSessionId)
|
|
2153
|
+
throw new ResourceConflict(`refusing to replace exact harness thread identity for ${id}; create a new governed session instead`)
|
|
2154
|
+
const codex = rec.harness === 'codex' || rec.harness === 'codex-headless'
|
|
2155
|
+
const root = runtimeRoot()
|
|
2156
|
+
let priorBinding: ReturnType<typeof codexGenerationBindingForSession> = null
|
|
2157
|
+
let generationId: string | undefined
|
|
2158
|
+
let registrationPrepared = false
|
|
2159
|
+
if (codex) {
|
|
2160
|
+
generationId = process.env.SPEXCODE_CODEX_GENERATION?.trim()
|
|
2161
|
+
const ledger = readCodexGenerationLedger(root)
|
|
2162
|
+
if (ledger.revision > 0 && !generationId) throw new ResourceConflict(`refusing to bind Codex thread ${harnessSessionId}: launch did not provide an exact generation id`)
|
|
2163
|
+
priorBinding = codexGenerationBindingForSession(root, id)
|
|
2164
|
+
if (priorBinding && (!generationId || priorBinding.generationId !== generationId || priorBinding.threadId !== harnessSessionId))
|
|
2165
|
+
throw new ResourceConflict(`refusing to replace exact Codex generation binding for ${id}`)
|
|
2166
|
+
if (generationId && !priorBinding) {
|
|
2167
|
+
prepareCodexGenerationRegistration(root, id, harnessSessionId, generationId)
|
|
2168
|
+
registrationPrepared = true
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
try {
|
|
2172
|
+
writeRecord({ ...rec, harnessSessionId, coldProof: null, adapterRecovery: null })
|
|
2173
|
+
} catch (error) {
|
|
2174
|
+
if (codex && generationId && registrationPrepared) {
|
|
2175
|
+
try {
|
|
2176
|
+
bindCodexGeneration(root, id, harnessSessionId, null)
|
|
2177
|
+
} catch (rollback) {
|
|
2178
|
+
throw new ResourceConflict(`Codex generation binding persisted but session ${id} record write failed and rollback failed: ${rollback instanceof Error ? rollback.message : String(rollback)}`)
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
throw error
|
|
2182
|
+
}
|
|
2183
|
+
if (codex && generationId) commitCodexGenerationRegistration(root, id, harnessSessionId, generationId)
|
|
2731
2184
|
return true
|
|
2732
|
-
})
|
|
2185
|
+
})
|
|
2733
2186
|
}
|
|
2734
|
-
// @@@ markIdle - the ONE INFERRED state, so (unlike the agent-authored writers above) it carries a strict
|
|
2735
|
-
// active-only guard: the Notification(idle_prompt) hook fires it when claude is waiting at its prompt, and it
|
|
2736
|
-
// may ONLY overwrite `active` → `idle`. A deliberate declaration (awaiting / asking / parked / error) must
|
|
2737
|
-
// survive — idle only fills the gap where the agent stopped WITHOUT declaring (e.g. an API error killed the
|
|
2738
|
-
// turn before the Stop gate). The mark-active hook flips idle → active on resume. Same id resolution as markState.
|
|
2739
2187
|
export function markIdle(sessionId?: string): boolean {
|
|
2740
2188
|
const id = sessionId || ownSessionId()
|
|
2741
2189
|
if (!id) return false
|
|
2742
|
-
return
|
|
2190
|
+
return withRecordLockSync(id, () => {
|
|
2743
2191
|
const rec = readLiveRecord(id)
|
|
2744
2192
|
if (!rec || rec.status !== 'active') return false // active-only: never clobber a declaration
|
|
2745
2193
|
writeRecord({ ...rec, status: 'idle' })
|
|
2746
2194
|
return true
|
|
2747
|
-
})
|
|
2195
|
+
})
|
|
2748
2196
|
}
|
|
2749
|
-
// @@@ asking has TWO writers, both deterministic (neither guarded active-only): (1) the mark-active
|
|
2750
|
-
// PreToolUse hook captures it the instant the agent invokes the AskUserQuestion tool (status=asking,
|
|
2751
|
-
// the question as the note) — a HARD signal that the agent is asking the human; (2) the agent declares it
|
|
2752
|
-
// itself via markState('asking', { note }) — `spex session ask`, e.g. at the Stop gate. Either
|
|
2753
|
-
// way the mark-active path clears it back to active on the next tool / prompt, same as any non-active state.
|
|
2754
|
-
|
|
2755
|
-
// @@@ mergeReadiness - the deterministic commit gate the Stop hook enforces before a session may declare
|
|
2756
|
-
// done. The dogfood ritual lands every change as a COMMIT on the node branch first, so an uncommitted
|
|
2757
|
-
// working tree blocks EITHER proposal: the declaration claims the work is committed, and a dirty tree makes
|
|
2758
|
-
// that false. The second condition — 0 commits ahead of main — is checked ONLY for `merge`, because it is
|
|
2759
|
-
// the only one the claim contradicts: `--propose merge` asserts there is committed work to land, while
|
|
2760
|
-
// `--propose nothing` asserts the opposite ("committed, but I am NOT proposing a merge; paused for the human
|
|
2761
|
-
// to look"), which a lane whose work ALREADY landed states truthfully. Gating `nothing` on ahead-of-main
|
|
2762
|
-
// left such a lane one way through: an empty commit — a lie in git history to satisfy a check about honesty. Since the global-store refactor, SpexCode writes NO per-session files into
|
|
2763
|
-
// the worktree (the runtime lives in ~/.spexcode), and the only in-tree SpexCode artifacts are exclude-
|
|
2764
|
-
// hidden materialized artifacts or filter-covered contract blocks ([[residence]]), so
|
|
2765
|
-
// neither shows as an uncommitted change — the worktree is pristine and EVERY dirty path is genuine spec/code
|
|
2766
|
-
// work, no runtime-file filtering needed.
|
|
2767
|
-
// Runs from cwd = the session worktree; ALL git goes through git() so the hook's exported GIT_DIR/GIT_INDEX_FILE
|
|
2768
|
-
// can't misdirect repo discovery to the cwd (the same trap git.ts documents). `main` resolves via the shared
|
|
2769
|
-
// refs, so `main..HEAD` works from any linked worktree regardless of where main is checked out.
|
|
2770
2197
|
export function mergeReadiness(proposal: 'merge' | 'nothing' = 'merge'): { ready: boolean; reason?: string } {
|
|
2771
2198
|
let dirty: string[] = []
|
|
2772
2199
|
try {
|
|
@@ -2793,27 +2220,14 @@ function porcelainPath(line: string): string {
|
|
|
2793
2220
|
return p
|
|
2794
2221
|
}
|
|
2795
2222
|
|
|
2796
|
-
// @@@ MANAGER COCKPIT - the review payload (the cockpit's first verb; see the manager-cockpit spec node).
|
|
2797
|
-
// One server-side bundle that lets a manager (human or agent) decide whether to merge a session WITHOUT
|
|
2798
|
-
// hand-running git: how far ahead it is, its REAL changes (merge-base diff, never a phantom main..HEAD one),
|
|
2799
|
-
// whether uncommitted non-runtime work remains, the merge/lint gates, and the agent's standing proposal.
|
|
2800
|
-
// ahead/dirty/diff/conflicts are computed against the SESSION's worktree (per id); lint reflects the CLI
|
|
2801
|
-
// package's OWN location (where this runs) — the spec-cli that's actually live. There is deliberately NO
|
|
2802
|
-
// build/typecheck/test gate: whether a change is SOUND is proven by the node's eval scenarios (measured through the
|
|
2803
|
-
// real product), not by a language-specific automated checker — so the gates stay language-agnostic (git +
|
|
2804
|
-
// the spec↔code graph, which every governed project has, TS or Python or otherwise). null when no session
|
|
2805
|
-
// has that id.
|
|
2806
|
-
// The measured-loss READOUT beside the git/graph gates. It grades nothing: no threshold, no pass/fail, no
|
|
2807
|
-
// block — the manager reads the four mutually exclusive scenario categories [[session-eval]] already folded
|
|
2808
|
-
// and decides. Its phase is part of the fact: a projection that has not been computed yet is NOT a clean
|
|
2809
|
-
// gate, so an absent/loading/updating/failed projection reports that phase and carries no numbers at all
|
|
2810
|
-
// rather than four honest-looking zeros.
|
|
2811
2223
|
export type ReviewEvalFacts = { freshPass: number; freshFail: number; needReview: number; blind: number }
|
|
2812
2224
|
export type ReviewEvalGate = ({ phase: 'ready' } & ReviewEvalFacts) | { phase: 'unavailable' | 'loading' | 'updating' | 'error' }
|
|
2225
|
+
// the session-side gates only. The measured-loss readout is composed ABOVE this layer ([[manager-cockpit]]'s
|
|
2226
|
+
// cockpit.ts): the eval package imports this module, so reading it from here could only ever be a deferred
|
|
2227
|
+
// import working around a cycle. The eval side never consumed this field — it reads lint/conflict/ahead/dirty.
|
|
2813
2228
|
export type ReviewGates = {
|
|
2814
2229
|
conflictsWithMain: boolean // a dry-run merge into main would conflict (in-memory, safe)
|
|
2815
2230
|
lint: { errorCount: number; warningCount: number } // the spec↔code graph lint
|
|
2816
|
-
evals: ReviewEvalGate // [[session-eval]]'s already-computed scenario categories
|
|
2817
2231
|
}
|
|
2818
2232
|
export type ReviewPayload = {
|
|
2819
2233
|
id: string; node: string | null; branch: string | null
|
|
@@ -2863,22 +2277,6 @@ async function lintGate(): Promise<ReviewGates['lint']> {
|
|
|
2863
2277
|
return p
|
|
2864
2278
|
}
|
|
2865
2279
|
|
|
2866
|
-
// @@@ evalGate - the cockpit's measured-loss readout, taken from [[session-eval]]'s EXISTING projection.
|
|
2867
|
-
// This is a cache READ and must stay one: buildSessionEvals() itself calls reviewPayload(), so building the
|
|
2868
|
-
// model from here would recurse. The import is dynamic for the same reason the lint gate's is — the eval
|
|
2869
|
-
// package imports this module, and the cockpit only needs it at call time.
|
|
2870
|
-
async function evalGate(id: string): Promise<ReviewEvalGate> {
|
|
2871
|
-
const { sessionEvalProjection } = await import('../../spec-eval/src/sessioneval.js')
|
|
2872
|
-
const projection = sessionEvalProjection(id)
|
|
2873
|
-
if (!projection) return { phase: 'unavailable' }
|
|
2874
|
-
// only a `ready` projection carries a CURRENT value; last-known is deliberately not reported as current.
|
|
2875
|
-
if (projection.phase !== 'ready' || !projection.value) {
|
|
2876
|
-
return { phase: projection.phase === 'ready' ? 'unavailable' : projection.phase }
|
|
2877
|
-
}
|
|
2878
|
-
const summary = projection.value
|
|
2879
|
-
return { phase: 'ready', freshPass: summary.pass, freshFail: summary.fail, needReview: summary.review, blind: summary.blind }
|
|
2880
|
-
}
|
|
2881
|
-
|
|
2882
2280
|
// @@@ reviewPayload - assemble the cockpit review for one session. The four session-specific reads
|
|
2883
2281
|
// (ahead / dirty / diff / conflict gate) plus the one location gate (lint) are all independent, so they run
|
|
2884
2282
|
// in parallel. The lint gate goes through lintGate(), which memoizes it on the checkout's tree fingerprint —
|
|
@@ -2888,13 +2286,12 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
2888
2286
|
const wt = await findWorktree(id)
|
|
2889
2287
|
if (!wt) return null
|
|
2890
2288
|
const base = mainBranch()
|
|
2891
|
-
const [aheadOut, statusOut, diff, conflictsWithMain, lint
|
|
2289
|
+
const [aheadOut, statusOut, diff, conflictsWithMain, lint] = await Promise.all([
|
|
2892
2290
|
gitA(['-C', wt.path, 'rev-list', '--count', `${base}..HEAD`]),
|
|
2893
2291
|
gitA(['-C', wt.path, 'status', '--porcelain', '--untracked-files=all']),
|
|
2894
2292
|
mergeBaseDiff(wt.path, base),
|
|
2895
2293
|
mergeConflicts(wt.path, base),
|
|
2896
2294
|
lintGate(), // lint — memoized on the checkout fingerprint, not re-run per session/open
|
|
2897
|
-
evalGate(id), // measured loss — a READ of the existing projection, never a build
|
|
2898
2295
|
])
|
|
2899
2296
|
// the worktree carries no SpexCode runtime files any more (the store lives in ~/.spexcode), so every dirty
|
|
2900
2297
|
// path is genuine work — this is just the total uncommitted count.
|
|
@@ -2904,22 +2301,11 @@ export async function reviewPayload(id: string): Promise<ReviewPayload | null> {
|
|
|
2904
2301
|
label: deriveLabel({ id, name: wt.rec.name, node: wt.rec.node, title: wt.rec.title, branch: wt.branch }),
|
|
2905
2302
|
ahead: Number(aheadOut.trim()) || 0,
|
|
2906
2303
|
dirtyNonRuntime, diff,
|
|
2907
|
-
gates: { conflictsWithMain, lint
|
|
2304
|
+
gates: { conflictsWithMain, lint },
|
|
2908
2305
|
proposal: { kind: wt.rec.proposal, note: wt.rec.note },
|
|
2909
2306
|
}
|
|
2910
2307
|
}
|
|
2911
2308
|
|
|
2912
|
-
// @@@ mergePrompt - the human's merge INTENT, handed to the session's OWN agent. Merge is a DISPATCH, not a
|
|
2913
|
-
// server git script: the agent knows the work, so IT runs the merge, resolves any conflicts, and VERIFIES the
|
|
2914
|
-
// outcome — the guarantee lives in that verification, never a server-side gate. This is also the ONE place the
|
|
2915
|
-
// merge STYLE is stated (no other mechanism carries it): a --no-ff merge commit `merge <branch>: <reason>`
|
|
2916
|
-
// into main. The agent runs git from the MAIN checkout (`-C <mainPath>`; its own cwd is the node worktree) —
|
|
2917
|
-
// and that checkout is the fleet's ONE landing door, so the prompt orders the landing to be TRIVIAL by the
|
|
2918
|
-
// time it gets there: sync + resolve in the agent's own worktree, land only when `merge-base --is-ancestor`
|
|
2919
|
-
// says the branch already contains the base, wait (never abort) on someone else's in-progress merge. The
|
|
2920
|
-
// always-on half of that contract is the `atomic-landing` system plugin; this prompt is the per-merge half.
|
|
2921
|
-
// After a clean merge the branch is 0 ahead of main, so the agent proposes CLOSE — not merge (the commit gate
|
|
2922
|
-
// would block a merge proposal; propose-close is exempt) — and the human confirms the close.
|
|
2923
2309
|
function mergePrompt(mainPath: string, branch: string, reason: string): string {
|
|
2924
2310
|
const base = mainBranch()
|
|
2925
2311
|
return `Merge your branch \`${branch}\` into \`${base}\`, then propose close. You know this work, so resolve any conflicts yourself — in YOUR OWN worktree, never in the shared ${base} checkout.\n\n` +
|
|
@@ -2930,15 +2316,6 @@ function mergePrompt(mainPath: string, branch: string, reason: string): string {
|
|
|
2930
2316
|
`5. Once you've verified \`${base}\` advanced cleanly, propose close for the human — do NOT close it yourself.`
|
|
2931
2317
|
}
|
|
2932
2318
|
|
|
2933
|
-
// @@@ mergeSession - the cockpit's ACT verb, the sequel to review — but a DISPATCH, not a server script: the
|
|
2934
|
-
// SESSION'S OWN agent lands the merge, never the server (it carries no `git merge` logic and never touches
|
|
2935
|
-
// main's tree). It resumes the session (clears the proposal, `--resume`s via resumeSession if tmux died —
|
|
2936
|
-
// which waits for the rendezvous socket, closing the just-relaunched-no-socket race) and dispatches mergePrompt
|
|
2937
|
-
// — that delivered prompt flips the lifecycle to active regardless of resume's resting state
|
|
2938
|
-
// through sendText. The reason = the node branch's latest commit subject minus a leading `spec: ` (visible from
|
|
2939
|
-
// the main checkout, no worktree path needed). Async + fail-loud: returns {dispatched:true} once the prompt is
|
|
2940
|
-
// CONFIRMED accepted, else {dispatched:false, reason} (the loud DispatchResult error). The server no longer
|
|
2941
|
-
// re-checks gates, runs git, bumps `merges`, or closes the session — review shows the gates; the agent verifies.
|
|
2942
2319
|
async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean; reason?: string }> {
|
|
2943
2320
|
const wt = await findWorktree(id)
|
|
2944
2321
|
if (!wt || !wt.branch) return { dispatched: false, reason: 'no such session' }
|
|
@@ -2954,7 +2331,7 @@ async function mergeSessionUnlocked(id: string): Promise<{ dispatched: boolean;
|
|
|
2954
2331
|
return { dispatched: true }
|
|
2955
2332
|
}
|
|
2956
2333
|
export const mergeSession = (id: string): Promise<{ dispatched: boolean; reason?: string }> =>
|
|
2957
|
-
|
|
2334
|
+
mergeSessionUnlocked(id)
|
|
2958
2335
|
|
|
2959
2336
|
// @@@ killAgentProcess - the pane is the agent's HOME, not its LEASH. `kill-session` SIGHUPs the pane's
|
|
2960
2337
|
// process group, and an idle agent goes with it (measured: ~0.8s) — but one mid-turn can outlive the whole
|
|
@@ -2974,9 +2351,7 @@ async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, l
|
|
|
2974
2351
|
throw new ResourceConflict(`refusing to stop ${id}: session leaf identity changed before signal`)
|
|
2975
2352
|
if (!Number.isFinite(pid) || pid <= 0) return
|
|
2976
2353
|
const startToken = leaf.startToken
|
|
2977
|
-
const alive = (): boolean =>
|
|
2978
|
-
try { process.kill(pid, 0); return true } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM' }
|
|
2979
|
-
}
|
|
2354
|
+
const alive = (): boolean => leafAlive(pid)
|
|
2980
2355
|
const identityState = (): 'same' | 'gone' | 'changed' => {
|
|
2981
2356
|
if (readAgentPid(sessionArtifactPath(id, 'agent.pid')) !== leaf.pid) return 'changed'
|
|
2982
2357
|
const current = processStartToken(pid)
|
|
@@ -3016,6 +2391,14 @@ async function killAgentProcess(id: string, beforeSignal: () => Promise<void>, l
|
|
|
3016
2391
|
// the resolved adapter to sweep its ephemeral runtime transport — in that order, because the adapter only
|
|
3017
2392
|
// removes a transport whose listener is PROVEN dead.
|
|
3018
2393
|
// Deliberately does NOT drainQueue — the caller drains once, after it has settled the worktree.
|
|
2394
|
+
// @@@ leafAlive - does this pid name a live process? EPERM counts as alive (a process we may not signal is
|
|
2395
|
+
// still a process); only ESRCH is absence. Kept local: git.ts carries its own copy for lock reclamation, and
|
|
2396
|
+
// collapsing the two is part of the spec/eval unification lane, not of this fix.
|
|
2397
|
+
const leafAlive = (pid: number): boolean => {
|
|
2398
|
+
try { process.kill(pid, 0); return true }
|
|
2399
|
+
catch (error) { return (error as NodeJS.ErrnoException)?.code !== 'ESRCH' }
|
|
2400
|
+
}
|
|
2401
|
+
|
|
3019
2402
|
async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIdentity | null> {
|
|
3020
2403
|
const harness = harnessById(rec.harness || defaultHarness.id)
|
|
3021
2404
|
if (harness.runtimeOwnership === 'adapter') return null
|
|
@@ -3028,8 +2411,8 @@ async function assertSessionLeafOwned(id: string, rec: SessRec): Promise<LeafIde
|
|
|
3028
2411
|
}
|
|
3029
2412
|
const startToken = processStartToken(pid)
|
|
3030
2413
|
if (!startToken) {
|
|
3031
|
-
if (rec.stopped) return null
|
|
3032
|
-
throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is
|
|
2414
|
+
if (rec.stopped || !leafAlive(pid)) return null
|
|
2415
|
+
throw new ResourceConflict(`refusing to stop ${id}: session-owned leaf PID ${pid} is alive but will not prove its start identity`)
|
|
3033
2416
|
}
|
|
3034
2417
|
const argv = await pexec('ps', ['-o', 'args=', '-p', String(pid)], { encoding: 'utf8' }).then((r) => r.stdout).catch(() => '')
|
|
3035
2418
|
const ownerNeedle = harness.leafOwnerNeedle?.(rec)
|
|
@@ -3062,11 +2445,6 @@ async function stopAgentProcess(id: string, rec: SessRec | null, requireCold = f
|
|
|
3062
2445
|
}
|
|
3063
2446
|
}
|
|
3064
2447
|
|
|
3065
|
-
// @@@ stopSession - the SOFT stop (vs closeSession's removal): stops the agent process but LEAVES the durable
|
|
3066
|
-
// worktree + branch + transcript intact. The retained record's explicit-stop marker makes liveness `offline`
|
|
3067
|
-
// whatever its lifecycle or adapter probe, so the relaunch panel offers to --resume the SAME conversation. This is
|
|
3068
|
-
// "step away, come back later"; closeSession is "discard this work". An offline session occupies no slot, so
|
|
3069
|
-
// the freed capacity drains a queued session next (drainQueue).
|
|
3070
2448
|
async function stopSessionUnlocked(id: string): Promise<boolean> {
|
|
3071
2449
|
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
3072
2450
|
try { wt = await findWorktree(id) }
|
|
@@ -3082,13 +2460,9 @@ async function stopSessionUnlocked(id: string): Promise<boolean> {
|
|
|
3082
2460
|
requestQueueDrain() // a stop frees a slot — start the next queued session if any
|
|
3083
2461
|
return !!wt
|
|
3084
2462
|
}
|
|
3085
|
-
export const stopSession = (id: string
|
|
3086
|
-
|
|
3087
|
-
() => withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id))))
|
|
2463
|
+
export const stopSession = (id: string): Promise<boolean> =>
|
|
2464
|
+
withSessionTransition(id, () => withRecordLock(id, () => stopSessionUnlocked(id)))
|
|
3088
2465
|
|
|
3089
|
-
// @@@ archiveSession - cold storage ([[archive]]): prove and stop the exact session-owned runtime first, then
|
|
3090
|
-
// write archived:true + stopped:true. The shared adapter root is never torn down. A guard/ownership failure
|
|
3091
|
-
// leaves the record unarchived and visible; no second cleanup primitive or fail-open record write exists.
|
|
3092
2466
|
async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
3093
2467
|
let wt: { path: string; branch: string | null; rec: SessRec } | null
|
|
3094
2468
|
try { wt = await findWorktree(id) }
|
|
@@ -3104,6 +2478,10 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
3104
2478
|
archiving.add(id)
|
|
3105
2479
|
try {
|
|
3106
2480
|
const h = harnessById(wt.rec.harness || defaultHarness.id)
|
|
2481
|
+
const settleArchiveRecovery = () => {
|
|
2482
|
+
const current = readRecord(id)
|
|
2483
|
+
if (current?.adapterRecovery) writeRecord({ ...current, adapterRecovery: null })
|
|
2484
|
+
}
|
|
3107
2485
|
// A proven cold record is already archived; never clear it and issue a second thread/archive RPC. Verify the
|
|
3108
2486
|
// adapter's exact resident reference first so an externally respawned thread is repaired rather than hidden.
|
|
3109
2487
|
if (wt.rec.archived && hasValidColdProof(wt.rec)) {
|
|
@@ -3122,11 +2500,17 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
3122
2500
|
const state = await descriptor.residency()
|
|
3123
2501
|
return state.healthy && state.rootAbsent === true && state.referenceIds.length === 0
|
|
3124
2502
|
})).then((states) => states.some(Boolean))
|
|
3125
|
-
if (rootAbsent)
|
|
2503
|
+
if (rootAbsent) {
|
|
2504
|
+
settleArchiveRecovery()
|
|
2505
|
+
return true
|
|
2506
|
+
}
|
|
3126
2507
|
const pre = await h.coldPreflight?.({ ...wt.rec, archived: false, stopped: true })
|
|
3127
2508
|
if (!pre || pre.ok) {
|
|
3128
2509
|
const cold = await h.coldRuntime?.({ ...wt.rec, archived: false, stopped: true }, pre?.ok ? pre.receipt : undefined)
|
|
3129
|
-
if (!cold || cold.ok)
|
|
2510
|
+
if (!cold || cold.ok) {
|
|
2511
|
+
settleArchiveRecovery()
|
|
2512
|
+
return true
|
|
2513
|
+
}
|
|
3130
2514
|
}
|
|
3131
2515
|
}
|
|
3132
2516
|
}
|
|
@@ -3168,7 +2552,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
3168
2552
|
: liveness({ ...latest, archived: false, stopped: false }, finalSnap)
|
|
3169
2553
|
if (finalLv === 'unknown' || finalLv === 'starting' || finalLv === 'online')
|
|
3170
2554
|
throw new ResourceConflict(`refusing to archive ${id}: leaf became ${finalLv} before filing`)
|
|
3171
|
-
writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest) })
|
|
2555
|
+
writeRecord({ ...latest, archived: true, stopped: true, coldProof: coldProofFor(latest), adapterRecovery: null })
|
|
3172
2556
|
} catch (error) {
|
|
3173
2557
|
if (coldCommitted) {
|
|
3174
2558
|
const restored = await h.restoreRuntime?.(wt.rec, preflight?.ok ? preflight.receipt : undefined)
|
|
@@ -3189,8 +2573,7 @@ async function archiveSessionUnlocked(id: string, on = true): Promise<boolean> {
|
|
|
3189
2573
|
}
|
|
3190
2574
|
export const archiveSession = (id: string, on = true): Promise<boolean> => {
|
|
3191
2575
|
if (!on) return archiveSessionUnarchive(id)
|
|
3192
|
-
return
|
|
3193
|
-
() => withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on))))
|
|
2576
|
+
return withSessionTransition(id, () => withRecordLock(id, () => archiveSessionUnlocked(id, on)))
|
|
3194
2577
|
}
|
|
3195
2578
|
async function archiveSessionUnarchive(id: string): Promise<boolean> {
|
|
3196
2579
|
const wt = await findWorktree(id)
|
|
@@ -3287,17 +2670,12 @@ async function assertQueuedRetirementSafe(id: string, rec: SessRec, path: string
|
|
|
3287
2670
|
}
|
|
3288
2671
|
}
|
|
3289
2672
|
|
|
3290
|
-
// @@@ closeSession - the REMOVAL (human-confirmed): a live row uses stop's exact kill, while a proven-cold
|
|
3291
|
-
// archive or never-launched queue uses its target-only read proof above. All then remove the worktree + branch and the session's whole
|
|
3292
|
-
// global-store record dir — the work is gone, not just stopped. The git/store teardown remains one path.
|
|
3293
|
-
// The tree's materialize slot ([[runtime]] trees/<enc>) retires with the worktree — its key needs the live tree,
|
|
3294
|
-
// so it is resolved BEFORE the removal; both sweeps are best-effort (residue is swept at uninstall anyway).
|
|
3295
|
-
// A corrupt record proves no adapter, leaf, worktree, or branch owner. Close may copy those bytes to the
|
|
3296
|
-
// control-plane quarantine, but then fails before this teardown seam and names every residue it preserved.
|
|
3297
2673
|
async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch: string | null; rec: SessRec }): Promise<boolean> {
|
|
3298
2674
|
const root = mainRoot()
|
|
3299
2675
|
const receiptFailure = publishedSessionCandidateReceiptRetirementFailure(wt.rec, root)
|
|
3300
2676
|
if (receiptFailure) throw new ResourceConflict(`refusing destructive close for ${id}: ${receiptFailure}; public record and resources remain the authority fence`)
|
|
2677
|
+
const closesCodexBinding = (wt.rec.harness === 'codex' || wt.rec.harness === 'codex-headless') && !!wt.rec.harnessSessionId
|
|
2678
|
+
if (closesCodexBinding) prepareCodexGenerationClose(runtimeRoot(), id, wt.rec.harnessSessionId!)
|
|
3301
2679
|
if (wt.rec.archived) await assertColdRetirementSafe(id, wt.rec)
|
|
3302
2680
|
else if (wt.rec.status === 'queued') await assertQueuedRetirementSafe(id, wt.rec, wt.path, wt.branch)
|
|
3303
2681
|
else await stopAgentProcess(id, wt.rec)
|
|
@@ -3325,6 +2703,9 @@ async function closeOwnedSessionUnlocked(id: string, wt: { path: string; branch:
|
|
|
3325
2703
|
try { rmSync(sessionStoreDir(id), { recursive: true, force: true }) }
|
|
3326
2704
|
catch (error) { throw new ResourceConflict(`refusing to finish close for ${id}: session record/prompt removal failed (${error instanceof Error ? error.message : String(error)})`) }
|
|
3327
2705
|
if (existsSync(sessionStoreDir(id))) throw new ResourceConflict(`refusing to finish close for ${id}: session record removal failed`)
|
|
2706
|
+
if (closesCodexBinding && wt.rec.harnessSessionId) {
|
|
2707
|
+
bindCodexGeneration(runtimeRoot(), id, wt.rec.harnessSessionId, null)
|
|
2708
|
+
}
|
|
3328
2709
|
requestQueueDrain() // a close frees a slot — start the next queued session if any
|
|
3329
2710
|
return true
|
|
3330
2711
|
}
|
|
@@ -3351,8 +2732,7 @@ async function closeSessionUnlocked(id: string): Promise<boolean> {
|
|
|
3351
2732
|
: closeOwnedSessionUnlocked(id, target)
|
|
3352
2733
|
}
|
|
3353
2734
|
export const closeSession = (id: string): Promise<boolean> =>
|
|
3354
|
-
|
|
3355
|
-
() => withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id))))
|
|
2735
|
+
withSessionTransition(id, () => withRecordLock(id, () => closeSessionUnlocked(id)))
|
|
3356
2736
|
|
|
3357
2737
|
export type CorruptRecordQuarantineWitness = {
|
|
3358
2738
|
adapter: string
|
|
@@ -3446,7 +2826,7 @@ async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuaranti
|
|
|
3446
2826
|
if (socket === 'unproven') throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} rendezvous transport absence is unknown`)
|
|
3447
2827
|
}
|
|
3448
2828
|
if (witness.thread) {
|
|
3449
|
-
if (!harness.quarantineOrphanThread) throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id}
|
|
2829
|
+
if (!harness.quarantineOrphanThread) throw new ResourceConflict(`refusing to quarantine ${id}: ${harness.id} has no archivable native thread; omit --thread (a SpexCode session id is not an adapter thread)`)
|
|
3450
2830
|
const native = await harness.quarantineOrphanThread(witness.thread, { excludingSessionId: id })
|
|
3451
2831
|
if (!native.ok) throw new ResourceConflict(`refusing to quarantine ${id}: ${native.reason}`)
|
|
3452
2832
|
return { adapter: native.audit.adapter, thread: native.audit.threadId, action: native.audit.action, compensate: native.compensate }
|
|
@@ -3464,11 +2844,8 @@ async function proveQuarantineAdapter(id: string, witness: CorruptRecordQuaranti
|
|
|
3464
2844
|
return { adapter: harness.id, thread: null, action: 'absent', compensate: async () => ({ ok: true }) }
|
|
3465
2845
|
}
|
|
3466
2846
|
|
|
3467
|
-
// @@@ quarantineCorruptRecord - the record-only escape hatch for an incident that has already lost parseability.
|
|
3468
|
-
// It never guesses from those bytes: the caller names the former residues, this function proves their absence,
|
|
3469
|
-
// and only then moves the opaque file into an auditable bundle. Close remains the destructive owner-based verb.
|
|
3470
2847
|
export async function quarantineCorruptRecord(id: string, rawWitness: unknown): Promise<CorruptRecordQuarantineResult> {
|
|
3471
|
-
return
|
|
2848
|
+
return withSessionTransition(id, () => withRecordLock(id, async () => {
|
|
3472
2849
|
const entry = readRecordEntry(id)
|
|
3473
2850
|
if (entry.kind === 'absent') throw new ResourceConflict(`refusing to quarantine ${id}: no active session record exists`)
|
|
3474
2851
|
if (entry.kind === 'ok') throw new ResourceConflict(`refusing to quarantine ${id}: record is readable; use its ordinary lifecycle control`)
|
|
@@ -3512,11 +2889,11 @@ export async function quarantineCorruptRecord(id: string, rawWitness: unknown):
|
|
|
3512
2889
|
throw new ResourceConflict(`refusing to quarantine ${id}: ${error instanceof Error ? error.message : String(error)}${suffix}`)
|
|
3513
2890
|
}
|
|
3514
2891
|
return { id, bundle, sha256, observedAt }
|
|
3515
|
-
}))
|
|
2892
|
+
}))
|
|
3516
2893
|
}
|
|
3517
2894
|
|
|
3518
2895
|
export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecordQuarantineResult> {
|
|
3519
|
-
return
|
|
2896
|
+
return withSessionTransition(id, () => withRecordLock(id, async () => {
|
|
3520
2897
|
if (readRecordEntry(id).kind !== 'absent') throw new ResourceConflict(`refusing to restore ${id}: an active session record already exists`)
|
|
3521
2898
|
let bundles: string[]
|
|
3522
2899
|
try { bundles = readdirSync(quarantineRoot(id), { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort().reverse() }
|
|
@@ -3542,13 +2919,9 @@ export async function restoreQuarantinedRecord(id: string): Promise<CorruptRecor
|
|
|
3542
2919
|
throw new ResourceConflict(`refusing to restore ${id}: restored record failed byte-exact verification`)
|
|
3543
2920
|
}
|
|
3544
2921
|
return { id, bundle, sha256: provenance.record.sha256, observedAt: new Date().toISOString() }
|
|
3545
|
-
}))
|
|
2922
|
+
}))
|
|
3546
2923
|
}
|
|
3547
2924
|
|
|
3548
|
-
// @@@ quarantine - closing sweeps the session's whole store dir, so an UNREADABLE record would take the only
|
|
3549
|
-
// evidence of what corrupted it with it. Copy those bytes to the per-project `corrupt/` shelf first, named by
|
|
3550
|
-
// session id and close time. Only unreadable records are shelved (a healthy one's contents are already known
|
|
3551
|
-
// and reproducible); the shelf is never read by the product, it is there for the human who asks "what broke?".
|
|
3552
2925
|
function quarantineRecord(id: string): string | null {
|
|
3553
2926
|
let entry
|
|
3554
2927
|
try { entry = readRecordEntry(id) } catch { return null } // unreadable for another reason (permissions) — leave it
|
|
@@ -3719,250 +3092,62 @@ export function formatTable(sessions: Session[], color = true): string {
|
|
|
3719
3092
|
return [c('1', `SpexCode sessions (${sessions.length})`), header, ...rows, statusLegend(color)].join('\n')
|
|
3720
3093
|
}
|
|
3721
3094
|
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
|
|
3726
|
-
|
|
3727
|
-
|
|
3728
|
-
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
3732
|
-
}
|
|
3733
|
-
export function sessionEvent(s: Session): string {
|
|
3734
|
-
const note = s.note ? ` — note: ${s.note}` : ''
|
|
3735
|
-
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
3736
|
-
return `[spex] ${s.status} · ${sessionLabel(s)} — act: ${NEXT[s.status] || '—'}${note}${asked} [id ${s.id}]`
|
|
3737
|
-
}
|
|
3738
|
-
// @@@ launchEvent - a session's FIRST sighting. A launch goes straight to 'working' (not actionable), so
|
|
3739
|
-
// without this the watch feed would be blind to new sessions starting. Emitted ONCE per id, regardless of
|
|
3740
|
-
// status, so `spex session watch` is a complete lifecycle feed: launched → [actionable transitions] → closed.
|
|
3741
|
-
export function launchEvent(s: Session): string {
|
|
3742
|
-
const note = s.note ? ` — note: ${s.note}` : ''
|
|
3743
|
-
const asked = s.promptPreview ? ` · asked: ${s.promptPreview}` : ''
|
|
3744
|
-
return `[spex] launched · ${sessionLabel(s)} — act: capture | send "<msg>"${note}${asked} [id ${s.id}]`
|
|
3745
|
-
}
|
|
3746
|
-
// @@@ source/presenceSource - event population and existence truth are deliberately separate projections. The
|
|
3747
|
-
// broad CLI watch passes the default active-only source so it never enumerates the archive shelf, plus an
|
|
3748
|
-
// all-record `presenceSource` so a hidden archive is not mistaken for a removed worktree. An explicit selector
|
|
3749
|
-
// passes the all-record source for BOTH, allowing that one target's offline archive/resume transitions through.
|
|
3750
|
-
// Both are backend clients (client.ts), so `spex session watch` monitors the machine named by
|
|
3751
|
-
// SPEXCODE_API_URL; a forgotten source must be a compile error, never a silent local-board fallback.
|
|
3752
|
-
export type WatchOpts = { source: () => Promise<Session[]>; presenceSource?: () => Promise<Session[]>; selectors?: string[]; statuses?: string[]; includeIdle?: boolean; intervalMs?: number; as?: string; until?: { timeoutMs: number; onObserved?: (status: DisplayStatus, previous: DisplayStatus | null) => void } }
|
|
3753
|
-
// @@@ watch outcome - only the BOUNDED `until` mode resolves (that mode is what `spex session wait` runs on); a
|
|
3754
|
-
// plain watch (no `until`) streams forever and never resolves. The bound is what makes `wait` a one-shot
|
|
3755
|
-
// "block for a worker's NEXT transition, then exit" that is GUARANTEED to return. Bounded mode is
|
|
3756
|
-
// EDGE-TRIGGERED ([[session-edges]]): it resolves only upon OBSERVING a watched target transition from a
|
|
3757
|
-
// non-actionable status INTO an actionable one — an already-actionable first sighting is recorded and
|
|
3758
|
-
// narrated (`onObserved`, arrival first) but never resolves, so "wait for the dispatched merge to actually
|
|
3759
|
-
// land" has a real signal instead of an instant false return on the standing `review` level. Every observed
|
|
3760
|
-
// status per target accumulates into a `path` (arrival first); the resolving target's path rides the outcome
|
|
3761
|
-
// so the caller can print the whole observed sequence. The deadline is checked EVERY poll, before
|
|
3762
|
-
// EVERY sleep (and even when a poll throws), so a target that never produces an edge — stuck in ANY
|
|
3763
|
-
// non-actionable state (`working`/`parked`/`idle`/`queued`/`starting`), or sitting forever on an
|
|
3764
|
-
// already-actionable level — can never hang the caller: it exits at the deadline, path carried for the report.
|
|
3765
|
-
// `reached` = an observed edge into an actionable status; the rest are the loud exits. `backendDown` is a verdict
|
|
3766
|
-
// about the TRANSPORT, never the session — `kind` keeps its two shapes distinct for the caller's outcome
|
|
3767
|
-
// surface: 'unreachable' (nothing listening, the whole timeout was spent retrying) vs 'http' (reachable but
|
|
3768
|
-
// broken, failed loud at once). The caller must surface these OUTSIDE the session-status vocabulary — a
|
|
3769
|
-
// supervisor must never be able to read a transport failure as a session state (issue #40).
|
|
3770
|
-
export type WatchOutcome = { reached: DisplayStatus; path: DisplayStatus[] } | { timedOut: true; path: DisplayStatus[] } | { gone: true; path: DisplayStatus[] } | { backendDown: string; kind: 'unreachable' | 'http' }
|
|
3771
|
-
export async function watchSessions(emit: (line: string) => void, opts: WatchOpts): Promise<WatchOutcome> {
|
|
3772
|
-
const { source, presenceSource = source, selectors = [], statuses, includeIdle = false, intervalMs = 5000, as, until } = opts
|
|
3773
|
-
const tag = as ? `[${as}] ` : ''
|
|
3774
|
-
const prev = new Map<string, DisplayStatus>()
|
|
3775
|
-
const paths = new Map<string, DisplayStatus[]>() // bounded mode: every status observed per target, arrival first
|
|
3776
|
-
const anyPath = () => (paths.size ? [...paths.values()][0] : [])
|
|
3777
|
-
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
|
3778
|
-
// the no-hang wall: a fixed deadline computed ONCE, checked unconditionally every iteration below.
|
|
3779
|
-
const deadline = until ? Date.now() + Math.max(1000, until.timeoutMs) : 0
|
|
3780
|
-
const isActionable = (st: DisplayStatus) => WATCH_ACTIONABLE.has(st) || (includeIdle && st === 'idle')
|
|
3781
|
-
let warnedDown = false
|
|
3782
|
-
let downMsg: string | null = null // set while the backend is unreachable, cleared on a good poll; the deadline reports it
|
|
3783
|
-
for (;;) {
|
|
3784
|
-
try {
|
|
3785
|
-
// EVENTS come from the caller's chosen population (broad watch uses active-only; an explicit selector
|
|
3786
|
-
// uses history). EXISTENCE always comes from the all-record presence source, so a cold archive can never
|
|
3787
|
-
// look removed merely because the default event population hides it. The `statuses` filter governs only
|
|
3788
|
-
// which transitions we emit, never whether a session is present.
|
|
3789
|
-
const eventRows = await source()
|
|
3790
|
-
// Explicit selector wait passes the same history function for both roles. Reuse that one snapshot so a
|
|
3791
|
-
// single poll cannot combine two HTTP instants (or pay for the same request twice); broad watch supplies
|
|
3792
|
-
// distinct function references because it genuinely needs active events plus all-record presence.
|
|
3793
|
-
const presenceRows = presenceSource === source ? eventRows : await presenceSource()
|
|
3794
|
-
const all = selectSessions(eventRows, selectors)
|
|
3795
|
-
const presence = selectSessions(presenceRows, selectors)
|
|
3796
|
-
warnedDown = false; downMsg = null // a successful poll re-arms the down-warning (and clears the deadline's down-report)
|
|
3797
|
-
const ids = new Set(presence.map((s) => s.id))
|
|
3798
|
-
const passesStatus = (st: DisplayStatus) => !statuses?.length || statuses.includes(st)
|
|
3799
|
-
let edge: { status: DisplayStatus; path: DisplayStatus[] } | null = null
|
|
3800
|
-
for (const s of all) {
|
|
3801
|
-
const was = prev.has(s.id) ? prev.get(s.id)! : null
|
|
3802
|
-
if (was === null) emit(tag + launchEvent(s)) // FIRST sighting → launched, any status (incl. 'working'), once
|
|
3803
|
-
if (s.status === was) continue // only on transition, not every tick
|
|
3804
|
-
prev.set(s.id, s.status)
|
|
3805
|
-
if (until) {
|
|
3806
|
-
const p = paths.get(s.id) ?? []
|
|
3807
|
-
p.push(s.status)
|
|
3808
|
-
paths.set(s.id, p)
|
|
3809
|
-
until.onObserved?.(s.status, was)
|
|
3810
|
-
// THE edge: a previously-observed NON-actionable status transitioning INTO an actionable one. An
|
|
3811
|
-
// actionable ARRIVAL (was === null) is deliberately not an edge — that standing level is what the
|
|
3812
|
-
// old wait false-returned on; and an actionable→actionable hop (review→done) isn't one either. The
|
|
3813
|
-
// rise out of non-actionable is the one signal that means "the target needs you AGAIN".
|
|
3814
|
-
if (was !== null && !isActionable(was) && isActionable(s.status)) edge = { status: s.status, path: p }
|
|
3815
|
-
}
|
|
3816
|
-
if (passesStatus(s.status) && (WATCH_ACTIONABLE.has(s.status) || (includeIdle && s.status === 'idle'))) emit(tag + sessionEvent(s))
|
|
3817
|
-
}
|
|
3818
|
-
// @@@ closed = the worktree is GONE. Because listSessions lists every EXISTING worktree (a flaky detail
|
|
3819
|
-
// read degrades, never drops), an id absent from the board means its worktree directory was actually
|
|
3820
|
-
// removed: a DEFINITIVE fact, not a flaky absence. So removal needs no 2-poll debounce / existsSync
|
|
3821
|
-
// re-check; emit `closed` exactly once the moment the id leaves the list.
|
|
3822
|
-
for (const id of [...prev.keys()]) {
|
|
3823
|
-
if (ids.has(id)) continue
|
|
3824
|
-
prev.delete(id)
|
|
3825
|
-
emit(`${tag}[spex] closed \u00b7 removed [id ${id}]`)
|
|
3826
|
-
}
|
|
3827
|
-
// BOUNDED mode (`until`, what `spex session wait` runs): return on an OBSERVED non-actionable→actionable
|
|
3828
|
-
// edge; an empty selected set means the target is gone (absent from the board), which it can never come
|
|
3829
|
-
// back from. Both sit inside the try, after the emit pass, so the caller still saw every transition
|
|
3830
|
-
// before we hand control back.
|
|
3831
|
-
if (until) {
|
|
3832
|
-
if (edge) return { reached: edge.status, path: edge.path }
|
|
3833
|
-
if (!presence.length) return { gone: true, path: anyPath() }
|
|
3834
|
-
}
|
|
3835
|
-
} catch (e) {
|
|
3836
|
-
// a backend error in the poll must NOT be swallowed AND must NOT emit a false `closed` for every session:
|
|
3837
|
-
// we skip the tick (prev is untouched → no phantom removals). Two shapes of BackendError diverge here:
|
|
3838
|
-
// • REACHABLE but erroring (an HTTP status) — the backend answered and is broken: a real terminal
|
|
3839
|
-
// condition, so a bounded `wait` fails loud IMMEDIATELY rather than spinning out its whole timeout.
|
|
3840
|
-
// • UNREACHABLE (no status — ECONNREFUSED / fetch failed) — nothing is listening, e.g. the supervisor
|
|
3841
|
-
// is rebooting its hot-reloaded child behind the stable port on a sibling merge. This is TRANSIENT:
|
|
3842
|
-
// record it, warn ONCE, and keep polling — the deadline (below) is the only hard wall, so a
|
|
3843
|
-
// backgrounded `spex session wait` survives the ~1s restart instead of dying on the interrupted fetch.
|
|
3844
|
-
if (until && isBackendDown(e) && !isBackendUnreachable(e)) return { backendDown: (e as Error).message, kind: 'http' }
|
|
3845
|
-
if (isBackendDown(e)) {
|
|
3846
|
-
downMsg = (e as Error).message
|
|
3847
|
-
if (!warnedDown) { warnedDown = true; console.error(`${tag}[spex] watch: ${downMsg}; retrying every ${intervalMs / 1000}s…`) }
|
|
3848
|
-
}
|
|
3849
|
-
}
|
|
3850
|
-
// the HARD wall — checked every iteration, in EVERY state, even after a thrown poll, BEFORE the sleep: this
|
|
3851
|
-
// guarantees `spex session wait` can never hang on a worker that never produces an edge — nor spin forever on a
|
|
3852
|
-
// backend that never comes back. Hitting the deadline while still unreachable reports THAT (`backendDown`),
|
|
3853
|
-
// not a false "no edge" timeout, so the manager sees the honest cause.
|
|
3854
|
-
if (until && Date.now() >= deadline) return downMsg ? { backendDown: downMsg, kind: 'unreachable' } : { timedOut: true, path: anyPath() }
|
|
3855
|
-
await sleep(intervalMs)
|
|
3856
|
-
}
|
|
3857
|
-
}
|
|
3858
|
-
|
|
3859
|
-
// @@@ sendText - PROMPT control for a session, delivered through the session's HARNESS ADAPTER
|
|
3860
|
-
// ([[harness-adapter]]) — claude the rendezvous control socket (atomic reply+repaint chunk: repaint-done proves
|
|
3861
|
-
// the daemon parsed it, a kicked connection resends, wall expiry on a live connection is busy-not-lost → ok),
|
|
3862
|
-
// codex app-server JSON-RPC into the visible TUI's thread. Either way there is NO silent
|
|
3863
|
-
// fallback: a prompt that can't be delivered — no socket / dead agent (claude), no app-server/thread (codex) — FAILS LOUD, returning
|
|
3864
|
-
// ok:false with a reason that propagates to the caller (API non-2xx, `spex session send`, the merge dispatch),
|
|
3865
|
-
// instead of reporting a false success. The harness is resolved from the record; an unknown id fails before any
|
|
3866
|
-
// harness transport is addressed. (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
3867
|
-
async function sendTextUnlocked(id: string, text: string, from?: string, opts: { replyVia?: 'note'; deliveryId?: string } = {}): Promise<DispatchResult> {
|
|
3095
|
+
// @@@ sendText - THE APPEND IS THE DELIVERY ([[dispatch]]). The message lands in the target's durable log
|
|
3096
|
+
// under its record lock, and success is decided there; only then is the harness adapter poked with the same
|
|
3097
|
+
// text, so a live agent sees it in its current turn instead of at its next turn boundary. The poke is
|
|
3098
|
+
// best-effort — losing it, having it refused, or replaying it costs nothing, because the line is already the
|
|
3099
|
+
// message's copy and the turn-boundary reader picks up whatever the poke did not show. What stays LOUD is only
|
|
3100
|
+
// what genuinely cannot be recorded: an unknown session id, or a log that refuses the write.
|
|
3101
|
+
// A RETIRED session (worktree gone) still receives: the record gate governs the lifecycle axis, and a message
|
|
3102
|
+
// that cannot reach an agent must at least leave a trace ([[session-timeline]]).
|
|
3103
|
+
// (The separate RAW nav-key channel keeps its own `tmux send-keys` path — see rawKey.)
|
|
3104
|
+
export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note' } = {}): Promise<DispatchResult> {
|
|
3868
3105
|
if (!text) return { ok: false, error: 'empty prompt — nothing to dispatch' }
|
|
3869
3106
|
const rec = readRecord(id)
|
|
3870
3107
|
if (!rec) return { ok: false, error: `no session record for ${id} — prompt NOT delivered` }
|
|
3108
|
+
const prompt = await composeSessionPrompt(text, rec, { from, replyVia: opts.replyVia })
|
|
3109
|
+
let sent: { mid: string }
|
|
3110
|
+
try {
|
|
3111
|
+
// The lock covers the append alone. Codex's native turn can synchronously run hooks that write this same
|
|
3112
|
+
// record, so holding it across the adapter poke below would deadlock the app-server's confirmation.
|
|
3113
|
+
sent = await withRecordLock(id, async () => appendSent(id, text, from ?? null, prompt.replyVia))
|
|
3114
|
+
} catch (error) {
|
|
3115
|
+
return { ok: false, error: `could not append the message to session ${id}'s log: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` }
|
|
3116
|
+
}
|
|
3871
3117
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3118
|
+
// Awaited, not fire-and-forget: `spex session send` is a short-lived process that would exit before an
|
|
3119
|
+
// unawaited poke ever reached the socket, costing every CLI send its same-turn arrival. Its result never
|
|
3120
|
+
// advances the inbox: a write cannot prove the target parsed it, so only the target's reader consumes the
|
|
3121
|
+
// durable line.
|
|
3122
|
+
await pokeAdapter(h, rec, prompt.text, sent.mid)
|
|
3123
|
+
return { ok: true }
|
|
3124
|
+
}
|
|
3125
|
+
|
|
3126
|
+
// The courtesy kick. Carries `mid` as the adapter's native message marker, so an adapter that replays or
|
|
3127
|
+
// duplicates it stays harmless. Never throws and never reports: a poke has no outcome the caller can act on.
|
|
3128
|
+
async function pokeAdapter(h: Harness, rec: SessRec, text: string, mid: string): Promise<void> {
|
|
3872
3129
|
// the pane guard ([[harness-adapter]] deliveryBlockedBy): the ONE pane state where the harness swallows a
|
|
3873
|
-
// prompt its channel confirms (claude's sessions panel), checkable only from the pane
|
|
3874
|
-
// the
|
|
3875
|
-
// the guard: the delivery channel itself is the authority on whether the agent is reachable.
|
|
3130
|
+
// prompt its channel confirms (claude's sessions panel), checkable only from the pane. It no longer refuses
|
|
3131
|
+
// the send — the message is already delivered — it only skips a kick known to be swallowed.
|
|
3876
3132
|
if (h.deliveryBlockedBy) {
|
|
3877
3133
|
try {
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
} catch { /* no pane to consult — let the delivery channel decide */ }
|
|
3134
|
+
if (h.deliveryBlockedBy(await tmux(['capture-pane', '-p', '-t', rec.session], TMUX_PROBE_TIMEOUT_MS))) return
|
|
3135
|
+
} catch { /* no pane to consult — let the poke itself decide */ }
|
|
3881
3136
|
}
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
// record the delivered agent-to-agent message ([[comms-edge]]): only when it carries a sender (an agent
|
|
3885
|
-
// send, not a raw human dispatch) and actually landed. Fire-and-forget — never gates the send result.
|
|
3886
|
-
if (r.ok && from) void recordComms(id, from)
|
|
3887
|
-
// the durable interaction history ([[session-timeline]]): every confirmed delivery is a `sent` event.
|
|
3888
|
-
if (r.ok) recordSent(id, text, from ?? null, prompt.replyVia)
|
|
3889
|
-
return r
|
|
3890
|
-
}
|
|
3891
|
-
|
|
3892
|
-
// A native request can commit after its client loses the response. Reserve a caller's opaque marker BEFORE
|
|
3893
|
-
// crossing that boundary, under the session lock, so a retry has one safe answer after a backend restart: the
|
|
3894
|
-
// stored terminal result, or commit-unknown for a reservation whose result could not be durably recorded.
|
|
3895
|
-
// This is session-generic idempotency; adapters only decide whether they can also carry the marker natively.
|
|
3896
|
-
type DeliveryLedgerEntry = { deliveryId: string; fingerprint: string; result?: DispatchResult }
|
|
3897
|
-
const deliveryLedgerPath = (id: string) => sessionArtifactPath(id, 'deliveries.ndjson')
|
|
3898
|
-
const deliveryFingerprint = (text: string, from?: string, replyVia?: 'note') =>
|
|
3899
|
-
createHash('sha256').update(JSON.stringify([text, from ?? null, replyVia ?? null])).digest('hex')
|
|
3900
|
-
function readDeliveryLedger(id: string, deliveryId: string): DeliveryLedgerEntry | null {
|
|
3901
|
-
try {
|
|
3902
|
-
const lines = readFileSync(deliveryLedgerPath(id), 'utf8').split('\n')
|
|
3903
|
-
for (let index = lines.length - 1; index >= 0; index--) {
|
|
3904
|
-
try {
|
|
3905
|
-
const entry = JSON.parse(lines[index]) as DeliveryLedgerEntry
|
|
3906
|
-
if (entry?.deliveryId === deliveryId && typeof entry.fingerprint === 'string') return entry
|
|
3907
|
-
} catch { /* one corrupt append must not invent a delivery result */ }
|
|
3908
|
-
}
|
|
3909
|
-
} catch { /* no delivery ledger yet */ }
|
|
3910
|
-
return null
|
|
3911
|
-
}
|
|
3912
|
-
function appendDeliveryLedger(id: string, entry: DeliveryLedgerEntry): void {
|
|
3913
|
-
appendFileSync(join(storeDir(id), 'deliveries.ndjson'), JSON.stringify(entry) + '\n')
|
|
3914
|
-
}
|
|
3915
|
-
function terminalDispatch(result: DispatchResult): DispatchResult {
|
|
3916
|
-
return { ...result, outcome: result.outcome ?? (result.ok ? 'accepted' : 'rejected') }
|
|
3917
|
-
}
|
|
3918
|
-
export async function sendText(id: string, text: string, from?: string, opts: { replyVia?: 'note'; deliveryId?: string } = {}): Promise<DispatchResult> {
|
|
3919
|
-
// The lock owns only the durable reservation and result. Codex's native turn can synchronously run hooks
|
|
3920
|
-
// that write this same record, so holding it across an adapter RPC deadlocks the app-server's confirmation.
|
|
3921
|
-
return runSessionOperation({ op: 'send', sessionId: id }, async () => {
|
|
3922
|
-
const deliveryId = opts.deliveryId?.trim()
|
|
3923
|
-
if (!deliveryId) return terminalDispatch(await sendTextUnlocked(id, text, from, opts))
|
|
3924
|
-
const fingerprint = deliveryFingerprint(text, from, opts.replyVia)
|
|
3925
|
-
const prior = await withRecordLock(id, async () => {
|
|
3926
|
-
const existing = readDeliveryLedger(id, deliveryId)
|
|
3927
|
-
if (existing) return existing
|
|
3928
|
-
try {
|
|
3929
|
-
appendDeliveryLedger(id, { deliveryId, fingerprint })
|
|
3930
|
-
return null
|
|
3931
|
-
} catch (error) {
|
|
3932
|
-
return { deliveryId, fingerprint, result: { ok: false, outcome: 'rejected', error: `could not reserve delivery marker: ${error instanceof Error ? error.message : String(error)} — prompt NOT delivered` } satisfies DispatchResult }
|
|
3933
|
-
}
|
|
3934
|
-
})
|
|
3935
|
-
if (prior) {
|
|
3936
|
-
if (prior.fingerprint !== fingerprint)
|
|
3937
|
-
return { ok: false, outcome: 'rejected', error: 'delivery marker belongs to a different prompt — prompt NOT delivered' }
|
|
3938
|
-
return prior.result
|
|
3939
|
-
? terminalDispatch(prior.result)
|
|
3940
|
-
: { ok: false, outcome: 'commit-unknown', error: 'delivery marker is already reserved without a terminal outcome — prompt NOT replayed' }
|
|
3941
|
-
}
|
|
3942
|
-
const result = terminalDispatch(await sendTextUnlocked(id, text, from, { ...opts, deliveryId }))
|
|
3943
|
-
try {
|
|
3944
|
-
return await withRecordLock(id, async () => {
|
|
3945
|
-
if (!readRecord(id))
|
|
3946
|
-
return { ok: false, outcome: 'commit-unknown', error: 'session closed before delivery outcome could be recorded — prompt NOT replayed' }
|
|
3947
|
-
appendDeliveryLedger(id, { deliveryId, fingerprint, result })
|
|
3948
|
-
return result
|
|
3949
|
-
})
|
|
3950
|
-
} catch (error) {
|
|
3951
|
-
return { ok: false, outcome: 'commit-unknown', error: `delivery outcome could not be recorded: ${error instanceof Error ? error.message : String(error)} — prompt NOT replayed` }
|
|
3952
|
-
}
|
|
3953
|
-
})
|
|
3137
|
+
try { await h.deliver({ ...rec, runtimeDir: runtimeRoot(), mid }, text) }
|
|
3138
|
+
catch { /* the unread timeline line remains the delivery */ }
|
|
3954
3139
|
}
|
|
3955
3140
|
|
|
3956
3141
|
// Hard interrupt is adapter-native control, distinct from stop's process teardown. A harness without a
|
|
3957
3142
|
// confirmed native primitive refuses loudly; there is no signal/PTY fallback that could target the wrong turn.
|
|
3958
3143
|
export async function interruptSession(id: string): Promise<DispatchResult> {
|
|
3959
|
-
return
|
|
3144
|
+
return withRecordLock(id, async () => {
|
|
3960
3145
|
const rec = readRecord(id)
|
|
3961
3146
|
if (!rec) return { ok: false, error: `no session record for ${id} - nothing to interrupt` }
|
|
3962
3147
|
const h = harnessById(rec.harness || defaultHarness.id)
|
|
3963
3148
|
if (!h.interrupt) return { ok: false, error: `harness ${h.id} has no native hard-interrupt control` }
|
|
3964
3149
|
return h.interrupt({ ...rec, runtimeDir: runtimeRoot() })
|
|
3965
|
-
})
|
|
3150
|
+
})
|
|
3966
3151
|
}
|
|
3967
3152
|
|
|
3968
3153
|
// @@@ rawKey - the RAW-KEYSTROKE nav path, kept DELIBERATELY on `tmux send-keys` and NEVER the rendezvous
|
|
@@ -4018,7 +3203,7 @@ function rawKeyArgs(id: string, key: string): string[] | null {
|
|
|
4018
3203
|
// (browser + server + send-keys all parallel) and scramble the sequence; a single serialised batch cannot.
|
|
4019
3204
|
// An unknown token is skipped without dropping the rest; false only if the tmux session is gone or nothing sent.
|
|
4020
3205
|
export async function rawKey(id: string, key: string | string[]): Promise<boolean> {
|
|
4021
|
-
return
|
|
3206
|
+
return withRecordLock(id, async () => {
|
|
4022
3207
|
const list = (Array.isArray(key) ? key : [key]).filter((k) => typeof k === 'string' && k.length > 0)
|
|
4023
3208
|
if (list.length === 0 || !(await alive(id))) return false
|
|
4024
3209
|
let sent = false
|
|
@@ -4028,5 +3213,5 @@ export async function rawKey(id: string, key: string | string[]): Promise<boolea
|
|
|
4028
3213
|
await tmux(args); sent = true
|
|
4029
3214
|
}
|
|
4030
3215
|
return sent
|
|
4031
|
-
})
|
|
3216
|
+
})
|
|
4032
3217
|
}
|