pi-agent-squad 0.8.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -1
- package/cypher-status.ts +401 -0
- package/index.ts +614 -103
- package/package.json +18 -1
- package/session.ts +2 -0
- package/spawn.ts +86 -39
- package/task-delivery.ts +201 -0
- package/task-recovery.ts +41 -0
- package/task-state.ts +286 -0
- package/task-status.ts +107 -0
package/README.md
CHANGED
|
@@ -64,6 +64,8 @@ metadata and does not sandbox filesystem access.
|
|
|
64
64
|
## Features
|
|
65
65
|
|
|
66
66
|
- **Delegation**: `subagent` tool (sync / background `async:true`); background results are injected into the main session when done.
|
|
67
|
+
- **Durable background ledger**: key background lifecycle snapshots are stored as TUI-only custom entries on the current Pi session branch.
|
|
68
|
+
- **Recovery and explicit retry**: reopening a session restores background history, marks stale work interrupted, repairs pending result delivery, and supports `/subagent-retry <runId>` without automatically rerunning old work.
|
|
67
69
|
- **Real-time two-way**: subagent<->main and subagent<->subagent, via file channel + an active-run registry that covers both direct runs and resident RPC processes.
|
|
68
70
|
- **Non-blocking**: background tasks do not occupy the main session.
|
|
69
71
|
- **Adaptive orchestration is opt-in**: `/orchestrate on` enables main's discretion to delegate based on speed, quality, context management, independent judgment, and parallel progress while weighing latency, over-analysis, misunderstanding, duplication, and integration risk.
|
|
@@ -100,7 +102,10 @@ The TUI-only widget is installed above the editor while at least one subagent is
|
|
|
100
102
|
the hint and hide it when the title itself needs the space.
|
|
101
103
|
- The elapsed time and spinner refresh once per second while work is active, avoiding hot-loop rerenders on very large sessions.
|
|
102
104
|
- Normal completion, failure, timeout, cancellation, crash, and session shutdown all remove the matching activity. The widget itself is removed when no activities remain.
|
|
103
|
-
- JSON/RPC/print modes do not install the widget.
|
|
105
|
+
- JSON/RPC/print modes do not install the widget. In RPC mode the same live runs
|
|
106
|
+
are instead published as the `cypher.subagents.v1` status snapshot (see
|
|
107
|
+
`cypher-status.ts`), which is what a GUI host such as Cypher renders in its
|
|
108
|
+
Subagents panel.
|
|
104
109
|
|
|
105
110
|
### Keyboard navigation
|
|
106
111
|
|
|
@@ -178,6 +183,39 @@ from the visible transcript:
|
|
|
178
183
|
`triggerTurn: true` and `deliverAs: "steer"`, preserving the former
|
|
179
184
|
synthetic-user-message behavior without inheriting `userMessageBg`.
|
|
180
185
|
|
|
186
|
+
## Background task persistence and recovery
|
|
187
|
+
|
|
188
|
+
Background task lifecycle state is append-only in the current Pi session. The
|
|
189
|
+
plugin writes `agent-squad-task-state` snapshots for `starting`, `running`,
|
|
190
|
+
`completed`, `failed`, `cancelled`, and `interrupted` transitions. These custom
|
|
191
|
+
entries do not enter LLM context.
|
|
192
|
+
|
|
193
|
+
When the same session is reopened, the plugin reconstructs the ledger from the
|
|
194
|
+
**active branch**. The last valid snapshot for each run is authoritative.
|
|
195
|
+
Malformed entries and unknown schema versions are ignored independently.
|
|
196
|
+
|
|
197
|
+
- A `starting` or `running` task owned by an older plugin runtime is changed to
|
|
198
|
+
`interrupted`; it is never assumed alive from a PID or child-session path.
|
|
199
|
+
- Interrupted, failed, and cancelled work is **not** automatically rerun. Use
|
|
200
|
+
`/subagent-retry <runId>` to create a new background run with a new run ID and
|
|
201
|
+
address. The new ledger entry records `retryOf`.
|
|
202
|
+
- Completion is persisted before its result is injected. If a completed result
|
|
203
|
+
was not delivered before shutdown, reopening the session schedules recovery
|
|
204
|
+
delivery after session initialization.
|
|
205
|
+
- Each completed result uses `agent-squad-result:<runId>` as its stable
|
|
206
|
+
`deliveryId`. Recovery scans durable custom-message entries for that ID before
|
|
207
|
+
sending and again immediately before delivery, preventing duplicate result
|
|
208
|
+
messages across crash windows.
|
|
209
|
+
- `/subagent-status` merges current live tasks with bounded current-branch
|
|
210
|
+
history and distinguishes delivered from pending completed results.
|
|
211
|
+
|
|
212
|
+
The ledger stores bounded metadata and result/error summaries, not complete
|
|
213
|
+
child transcripts, credentials, controllers, promises, streams, or callbacks.
|
|
214
|
+
Background child Pi sessions remain separate and retain their full transcript;
|
|
215
|
+
the ledger stores only the bounded summary plus the child session file path.
|
|
216
|
+
This version does **not** revive dead OS processes, take over processes from an
|
|
217
|
+
older runtime, daemonize subagents, or provide cross-runtime process keepalive.
|
|
218
|
+
|
|
181
219
|
## Architecture
|
|
182
220
|
|
|
183
221
|
```
|
|
@@ -210,6 +248,11 @@ subagents/
|
|
|
210
248
|
|-- message.ts # generic messaging (file channel + send/reply/read + main-side router)
|
|
211
249
|
|-- session.ts # common interactive session-handle interface
|
|
212
250
|
|-- session-ui.ts # focused overlay for live transcript + interactive input
|
|
251
|
+
|-- cypher-status.ts # `cypher.subagents.v1` live run projection for GUI hosts (RPC mode)
|
|
252
|
+
|-- task-state.ts # durable schema, validation, and branch reconstruction
|
|
253
|
+
|-- task-recovery.ts # stale-run and undelivered-result reconciliation planning
|
|
254
|
+
|-- task-delivery.ts # delivery IDs, durable dedupe, and recovery outbox
|
|
255
|
+
|-- task-status.ts # bounded live + persisted status formatting
|
|
213
256
|
|-- orchestrator.md # main-agent adaptive delegation prompt (enabled with /orchestrate on)
|
|
214
257
|
`-- README.md
|
|
215
258
|
```
|
|
@@ -230,6 +273,9 @@ pi --append-system-prompt ~/.pi/agent/extensions/subagents/orchestrator.md
|
|
|
230
273
|
"Use subagent async=true, agent=actor, task=..." # explicit background delegation
|
|
231
274
|
"Use subagent agent=reviewer timeoutSeconds=120 ..." # override the default 6h task timeout (only when the user asked)
|
|
232
275
|
"Have reviewer review the recent changes" # main agent delegates to reviewer
|
|
276
|
+
|
|
277
|
+
/subagent-status # live tasks plus bounded current-branch history
|
|
278
|
+
/subagent-retry <full-run-id> # explicit retry; creates a new run id/address
|
|
233
279
|
```
|
|
234
280
|
|
|
235
281
|
Main is not required to follow a fixed planner → actor → reviewer chain. It may
|
package/cypher-status.ts
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cypher subagent status protocol (`cypher.subagents.v1`).
|
|
3
|
+
*
|
|
4
|
+
* Cypher runs Pi in RPC mode and has no terminal to render the TUI widget
|
|
5
|
+
* into. Instead it consumes a STRUCTURED live projection published through
|
|
6
|
+
* `ctx.ui.setStatus`: the one status key Cypher parses instead of treating as
|
|
7
|
+
* transient TUI furniture. Without it a subagent run shows as an eternal
|
|
8
|
+
* "starting" row in Cypher's Subagents inspector, because the only other
|
|
9
|
+
* signal it has is an unresolved tool call.
|
|
10
|
+
*
|
|
11
|
+
* Contract (mirrors Cypher's `parse_subagent_status`, which validates every
|
|
12
|
+
* field strictly and DROPS THE WHOLE SNAPSHOT on any violation):
|
|
13
|
+
* - key `cypher.subagents.v1`, value `JSON.stringify({version: 1, runs: […]})`;
|
|
14
|
+
* blank text is a CLEAR snapshot.
|
|
15
|
+
* - per run: `runId` + `agent` + `mode` (`sync|async|message`) + `status`
|
|
16
|
+
* (`running|done|error`) + `startedAt`/`updatedAt` epoch millis are
|
|
17
|
+
* required; `toolCallId`, `model`, `task`, `progress`, `endedAt` optional.
|
|
18
|
+
* - bounds: ≤32 runs, task ≤500 chars, progress ≤8 lines / 4KiB, snapshot
|
|
19
|
+
* ≤64KiB.
|
|
20
|
+
*
|
|
21
|
+
* `updatedAt` doubles as the heartbeat: Cypher greys a run out as stale after
|
|
22
|
+
* 45s of silence, so live runs are republished on a timer.
|
|
23
|
+
*
|
|
24
|
+
* RPC mode only — the TUI keeps its own widget and must never be handed this
|
|
25
|
+
* JSON. A standalone Pi (no Cypher) simply never attaches.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export const CYPHER_SUBAGENT_STATUS_KEY = "cypher.subagents.v1";
|
|
29
|
+
|
|
30
|
+
const SNAPSHOT_VERSION = 1;
|
|
31
|
+
/** Snapshot bounds — Cypher re-checks each one and ignores a snapshot that breaks any. */
|
|
32
|
+
const MAX_RUNS = 32;
|
|
33
|
+
const MAX_TASK_CHARS = 500;
|
|
34
|
+
const MAX_PROGRESS_LINES = 8;
|
|
35
|
+
const MAX_PROGRESS_BYTES = 4096;
|
|
36
|
+
const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
|
37
|
+
/** Settled runs kept in the snapshot so the panel can show Done/Error instead of a row that vanishes. */
|
|
38
|
+
const MAX_TERMINAL_RUNS = 8;
|
|
39
|
+
/** Progress tail kept per run (Cypher renders the last line on the row). */
|
|
40
|
+
const MAX_PROGRESS_KEPT = 6;
|
|
41
|
+
const MAX_PROGRESS_LINE_CHARS = 160;
|
|
42
|
+
/** Republish period for live runs; Cypher's staleness window is 45s. */
|
|
43
|
+
const HEARTBEAT_MS = 10_000;
|
|
44
|
+
/**
|
|
45
|
+
* Floor between progress-driven publishes. Every snapshot Cypher accepts is a
|
|
46
|
+
* synced session-row write, and a busy child emits tool events several times a
|
|
47
|
+
* second — lifecycle changes publish immediately, chatter coalesces.
|
|
48
|
+
*/
|
|
49
|
+
const MIN_PUBLISH_INTERVAL_MS = 750;
|
|
50
|
+
|
|
51
|
+
export type CypherRunMode = "sync" | "async" | "message";
|
|
52
|
+
export type CypherRunStatus = "running" | "done" | "error";
|
|
53
|
+
|
|
54
|
+
/** One run as published. Field names are the wire format — camelCase, epoch millis. */
|
|
55
|
+
export interface CypherRun {
|
|
56
|
+
runId: string;
|
|
57
|
+
toolCallId?: string;
|
|
58
|
+
agent: string;
|
|
59
|
+
model?: string;
|
|
60
|
+
task: string;
|
|
61
|
+
mode: CypherRunMode;
|
|
62
|
+
status: CypherRunStatus;
|
|
63
|
+
progress?: string;
|
|
64
|
+
startedAt: number;
|
|
65
|
+
updatedAt: number;
|
|
66
|
+
endedAt?: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CypherStatusSnapshot {
|
|
70
|
+
version: number;
|
|
71
|
+
runs: CypherRun[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface StartRunInput {
|
|
75
|
+
runId: string;
|
|
76
|
+
agent: string;
|
|
77
|
+
task: string;
|
|
78
|
+
mode: CypherRunMode;
|
|
79
|
+
/** The parent tool call this run answers to (sync/async); absent for message activity. */
|
|
80
|
+
toolCallId?: string;
|
|
81
|
+
model?: string;
|
|
82
|
+
startedAt?: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface StatusUi {
|
|
86
|
+
setStatus(key: string, text: string | undefined): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface StatusContext {
|
|
90
|
+
mode?: string;
|
|
91
|
+
ui?: Partial<StatusUi>;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function boundText(value: string, maxChars: number): string {
|
|
95
|
+
const text = String(value ?? "").replace(/\s+/g, " ").trim();
|
|
96
|
+
if (text.length <= maxChars) return text;
|
|
97
|
+
// Cut on a code point boundary: Cypher counts characters, not UTF-16 units.
|
|
98
|
+
return `${[...text].slice(0, Math.max(0, maxChars - 1)).join("")}…`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function boundLine(value: string): string {
|
|
102
|
+
const stripped = String(value ?? "").replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
|
|
103
|
+
return boundText(stripped, MAX_PROGRESS_LINE_CHARS);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Join the kept tail within BOTH Cypher's line and byte caps. */
|
|
107
|
+
function renderProgress(lines: string[]): string | undefined {
|
|
108
|
+
let kept = lines.filter((line) => line.length > 0).slice(-MAX_PROGRESS_LINES);
|
|
109
|
+
while (kept.length > 0 && Buffer.byteLength(kept.join("\n"), "utf8") > MAX_PROGRESS_BYTES) {
|
|
110
|
+
kept = kept.slice(1);
|
|
111
|
+
}
|
|
112
|
+
const text = kept.join("\n");
|
|
113
|
+
return text.length > 0 ? text : undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A short, human-readable argument for a child tool call ("bash: cargo test"). */
|
|
117
|
+
function summarizeToolArgs(args: unknown): string {
|
|
118
|
+
if (!args || typeof args !== "object") return "";
|
|
119
|
+
const record = args as Record<string, unknown>;
|
|
120
|
+
for (const key of ["command", "path", "file_path", "pattern", "query", "url"]) {
|
|
121
|
+
const value = record[key];
|
|
122
|
+
if (typeof value === "string" && value.trim()) return boundText(value, 60);
|
|
123
|
+
}
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** First meaningful line of an assistant message's text content. */
|
|
128
|
+
function assistantHeadline(message: any): string {
|
|
129
|
+
const content = message?.content;
|
|
130
|
+
const parts = Array.isArray(content) ? content : [];
|
|
131
|
+
for (const part of parts) {
|
|
132
|
+
const text = typeof part?.text === "string" ? part.text : "";
|
|
133
|
+
const line = text.split("\n").map((l: string) => l.trim()).find((l: string) => l.length > 0);
|
|
134
|
+
if (line) return boundLine(line);
|
|
135
|
+
}
|
|
136
|
+
if (typeof content === "string") {
|
|
137
|
+
const line = content.split("\n").map((l) => l.trim()).find((l) => l.length > 0);
|
|
138
|
+
if (line) return boundLine(line);
|
|
139
|
+
}
|
|
140
|
+
return "";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The live run ledger Cypher reads. Deliberately independent of the TUI
|
|
145
|
+
* widget: the widget is presentation for a terminal, this is a protocol.
|
|
146
|
+
*/
|
|
147
|
+
export class CypherStatusPublisher {
|
|
148
|
+
private runs = new Map<string, CypherRun>();
|
|
149
|
+
private progress = new Map<string, string[]>();
|
|
150
|
+
private ui: StatusUi | undefined;
|
|
151
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
152
|
+
private pending: ReturnType<typeof setTimeout> | undefined;
|
|
153
|
+
private lastPublishAt = 0;
|
|
154
|
+
/** Last published text — an unchanged snapshot is never re-sent. */
|
|
155
|
+
private lastPublished: string | undefined;
|
|
156
|
+
|
|
157
|
+
/** RPC mode only. Re-attaching (a new session) keeps whatever is live. */
|
|
158
|
+
attach(ctx: StatusContext | undefined): void {
|
|
159
|
+
const ui = ctx?.mode === "rpc" ? ctx.ui : undefined;
|
|
160
|
+
this.ui = typeof ui?.setStatus === "function" ? (ui as StatusUi) : undefined;
|
|
161
|
+
this.lastPublished = undefined;
|
|
162
|
+
if (!this.ui) {
|
|
163
|
+
this.stopHeartbeat();
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (this.liveCount() > 0) this.startHeartbeat();
|
|
167
|
+
this.publish(true);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Session shutdown: clear the board so Cypher never keeps a ghost runner. */
|
|
171
|
+
shutdown(): void {
|
|
172
|
+
this.runs.clear();
|
|
173
|
+
this.progress.clear();
|
|
174
|
+
this.stopHeartbeat();
|
|
175
|
+
this.cancelPending();
|
|
176
|
+
if (this.ui) {
|
|
177
|
+
this.ui.setStatus(CYPHER_SUBAGENT_STATUS_KEY, undefined);
|
|
178
|
+
this.lastPublished = undefined;
|
|
179
|
+
}
|
|
180
|
+
this.ui = undefined;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get size(): number {
|
|
184
|
+
return this.runs.size;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Is this run still published as running? */
|
|
188
|
+
isLive(runId: string): boolean {
|
|
189
|
+
return this.runs.get(runId)?.status === "running";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Snapshot the publisher would send right now (the unit-test surface). */
|
|
193
|
+
snapshot(): CypherStatusSnapshot {
|
|
194
|
+
return { version: SNAPSHOT_VERSION, runs: this.orderedRuns() };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
start(input: StartRunInput): void {
|
|
198
|
+
const now = Date.now();
|
|
199
|
+
const startedAt = input.startedAt ?? now;
|
|
200
|
+
this.runs.set(input.runId, {
|
|
201
|
+
runId: input.runId,
|
|
202
|
+
...(input.toolCallId ? { toolCallId: input.toolCallId } : {}),
|
|
203
|
+
agent: boundText(input.agent, 120) || "subagent",
|
|
204
|
+
...(input.model ? { model: boundText(input.model, 120) } : {}),
|
|
205
|
+
task: boundText(input.task, MAX_TASK_CHARS),
|
|
206
|
+
mode: input.mode,
|
|
207
|
+
status: "running",
|
|
208
|
+
startedAt,
|
|
209
|
+
updatedAt: now,
|
|
210
|
+
});
|
|
211
|
+
this.progress.delete(input.runId);
|
|
212
|
+
this.startHeartbeat();
|
|
213
|
+
this.publish(true);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Model discovered mid-run, or a new progress line. No-op for unknown runs. */
|
|
217
|
+
update(runId: string, patch: { model?: string; progressLine?: string }): void {
|
|
218
|
+
const run = this.runs.get(runId);
|
|
219
|
+
if (!run || run.status !== "running") return;
|
|
220
|
+
let changed = false;
|
|
221
|
+
if (patch.model && !run.model) {
|
|
222
|
+
run.model = boundText(patch.model, 120);
|
|
223
|
+
changed = true;
|
|
224
|
+
}
|
|
225
|
+
const line = patch.progressLine ? boundLine(patch.progressLine) : "";
|
|
226
|
+
if (line) {
|
|
227
|
+
const lines = this.progress.get(runId) ?? [];
|
|
228
|
+
// Consecutive duplicates are noise (a tool retried on every chunk).
|
|
229
|
+
if (lines[lines.length - 1] !== line) {
|
|
230
|
+
lines.push(line);
|
|
231
|
+
this.progress.set(runId, lines.slice(-MAX_PROGRESS_KEPT));
|
|
232
|
+
changed = true;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
if (!changed) return;
|
|
236
|
+
run.updatedAt = Date.now();
|
|
237
|
+
this.publish();
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Terminalize a run only if it is still live — a teardown path that can run
|
|
242
|
+
* after a specific `finish` (or after the owner lost interest) must never
|
|
243
|
+
* leave a runner published forever.
|
|
244
|
+
*/
|
|
245
|
+
settleIfLive(runId: string, status: Exclude<CypherRunStatus, "running">, detail?: string): void {
|
|
246
|
+
if (!this.isLive(runId)) return;
|
|
247
|
+
this.finish(runId, status, detail);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
finish(runId: string, status: Exclude<CypherRunStatus, "running">, detail?: string): void {
|
|
251
|
+
const run = this.runs.get(runId);
|
|
252
|
+
if (!run) return;
|
|
253
|
+
const now = Date.now();
|
|
254
|
+
run.status = status;
|
|
255
|
+
run.updatedAt = now;
|
|
256
|
+
run.endedAt = now;
|
|
257
|
+
const line = detail ? boundLine(detail) : "";
|
|
258
|
+
if (line) {
|
|
259
|
+
const lines = this.progress.get(runId) ?? [];
|
|
260
|
+
lines.push(line);
|
|
261
|
+
this.progress.set(runId, lines.slice(-MAX_PROGRESS_KEPT));
|
|
262
|
+
}
|
|
263
|
+
this.trimTerminal();
|
|
264
|
+
if (this.liveCount() === 0) this.stopHeartbeat();
|
|
265
|
+
this.publish(true);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Child Pi RPC events → model + a progress tail. Best effort: the run is
|
|
270
|
+
* still reported without any of this.
|
|
271
|
+
*/
|
|
272
|
+
observeChildEvent(runId: string, event: any): void {
|
|
273
|
+
if (!this.runs.has(runId)) return;
|
|
274
|
+
// This runs inside the child's event dispatch, which does NOT guard its
|
|
275
|
+
// callbacks: a throw here would break the run itself.
|
|
276
|
+
try {
|
|
277
|
+
const type = event?.type;
|
|
278
|
+
if (type === "tool_execution_start") {
|
|
279
|
+
const name = String(event?.toolName ?? "tool");
|
|
280
|
+
const args = summarizeToolArgs(event?.args);
|
|
281
|
+
this.update(runId, { progressLine: args ? `${name}: ${args}` : name });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (type === "message_end" && event?.message?.role === "assistant") {
|
|
285
|
+
const model = typeof event.message.model === "string" ? event.message.model : undefined;
|
|
286
|
+
this.update(runId, { model, progressLine: assistantHeadline(event.message) });
|
|
287
|
+
}
|
|
288
|
+
} catch {
|
|
289
|
+
/* a malformed child event is never worth a failed run */
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private liveCount(): number {
|
|
294
|
+
let live = 0;
|
|
295
|
+
for (const run of this.runs.values()) if (run.status === "running") live++;
|
|
296
|
+
return live;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Live first (oldest first), then the most recently settled. */
|
|
300
|
+
private orderedRuns(): CypherRun[] {
|
|
301
|
+
const all = [...this.runs.values()];
|
|
302
|
+
const live = all.filter((run) => run.status === "running").sort((a, b) => a.startedAt - b.startedAt);
|
|
303
|
+
const settled = all
|
|
304
|
+
.filter((run) => run.status !== "running")
|
|
305
|
+
.sort((a, b) => (b.endedAt ?? b.updatedAt) - (a.endedAt ?? a.updatedAt));
|
|
306
|
+
return [...live, ...settled].slice(0, MAX_RUNS).map((run) => {
|
|
307
|
+
const progress = renderProgress(this.progress.get(run.runId) ?? []);
|
|
308
|
+
return progress ? { ...run, progress } : { ...run };
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** Keep the settled tail bounded so a long session never grows the board. */
|
|
313
|
+
private trimTerminal(): void {
|
|
314
|
+
const settled = [...this.runs.values()]
|
|
315
|
+
.filter((run) => run.status !== "running")
|
|
316
|
+
.sort((a, b) => (b.endedAt ?? b.updatedAt) - (a.endedAt ?? a.updatedAt));
|
|
317
|
+
for (const run of settled.slice(MAX_TERMINAL_RUNS)) {
|
|
318
|
+
this.runs.delete(run.runId);
|
|
319
|
+
this.progress.delete(run.runId);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
private startHeartbeat(): void {
|
|
324
|
+
// Nothing consumes the snapshot outside Cypher, so a standalone TUI must
|
|
325
|
+
// not even carry the timer.
|
|
326
|
+
if (!this.ui || this.timer) return;
|
|
327
|
+
this.timer = setInterval(() => {
|
|
328
|
+
if (this.liveCount() === 0) {
|
|
329
|
+
this.stopHeartbeat();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const now = Date.now();
|
|
333
|
+
for (const run of this.runs.values()) if (run.status === "running") run.updatedAt = now;
|
|
334
|
+
this.publish(true);
|
|
335
|
+
}, HEARTBEAT_MS);
|
|
336
|
+
// A heartbeat must never hold the process open.
|
|
337
|
+
this.timer.unref?.();
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private stopHeartbeat(): void {
|
|
341
|
+
if (!this.timer) return;
|
|
342
|
+
clearInterval(this.timer);
|
|
343
|
+
this.timer = undefined;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** `immediate` = a lifecycle change; anything else coalesces. */
|
|
347
|
+
private publish(immediate = false): void {
|
|
348
|
+
if (!this.ui) return;
|
|
349
|
+
const waited = Date.now() - this.lastPublishAt;
|
|
350
|
+
if (!immediate && waited < MIN_PUBLISH_INTERVAL_MS) {
|
|
351
|
+
if (this.pending) return;
|
|
352
|
+
this.pending = setTimeout(() => {
|
|
353
|
+
this.pending = undefined;
|
|
354
|
+
this.publishNow();
|
|
355
|
+
}, MIN_PUBLISH_INTERVAL_MS - waited);
|
|
356
|
+
this.pending.unref?.();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
this.cancelPending();
|
|
360
|
+
this.publishNow();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private publishNow(): void {
|
|
364
|
+
if (!this.ui) return;
|
|
365
|
+
const text = serializeSnapshot(this.orderedRuns());
|
|
366
|
+
this.lastPublishAt = Date.now();
|
|
367
|
+
if (text === this.lastPublished) return;
|
|
368
|
+
this.lastPublished = text;
|
|
369
|
+
try {
|
|
370
|
+
this.ui.setStatus(CYPHER_SUBAGENT_STATUS_KEY, text);
|
|
371
|
+
} catch {
|
|
372
|
+
/* a status frame must never break a run */
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
private cancelPending(): void {
|
|
377
|
+
if (!this.pending) return;
|
|
378
|
+
clearTimeout(this.pending);
|
|
379
|
+
this.pending = undefined;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Serialize within the 64KiB cap. Over budget, progress goes first, then the
|
|
385
|
+
* settled tail — the live runs are the part Cypher cannot re-derive.
|
|
386
|
+
*/
|
|
387
|
+
export function serializeSnapshot(runs: CypherRun[]): string {
|
|
388
|
+
const encode = (list: CypherRun[]) => JSON.stringify({ version: SNAPSHOT_VERSION, runs: list });
|
|
389
|
+
let text = encode(runs);
|
|
390
|
+
if (Buffer.byteLength(text, "utf8") <= MAX_SNAPSHOT_BYTES) return text;
|
|
391
|
+
const withoutProgress = runs.map(({ progress: _progress, ...run }) => run as CypherRun);
|
|
392
|
+
text = encode(withoutProgress);
|
|
393
|
+
if (Buffer.byteLength(text, "utf8") <= MAX_SNAPSHOT_BYTES) return text;
|
|
394
|
+
let live = withoutProgress.filter((run) => run.status === "running");
|
|
395
|
+
text = encode(live);
|
|
396
|
+
while (live.length > 1 && Buffer.byteLength(text, "utf8") > MAX_SNAPSHOT_BYTES) {
|
|
397
|
+
live = live.slice(0, Math.floor(live.length / 2));
|
|
398
|
+
text = encode(live);
|
|
399
|
+
}
|
|
400
|
+
return text;
|
|
401
|
+
}
|