pi-herdr-subagents 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +55 -0
- package/.pi/settings.json +8 -0
- package/.pi/skills/run-integration-tests/SKILL.md +28 -0
- package/LICENSE +21 -0
- package/README.md +483 -0
- package/RELEASING.md +103 -0
- package/agents/planner.md +546 -0
- package/agents/reviewer.md +150 -0
- package/agents/scout.md +104 -0
- package/agents/visual-tester.md +197 -0
- package/agents/worker.md +103 -0
- package/config.json.example +5 -0
- package/package.json +34 -0
- package/pi-extension/subagents/activity.ts +511 -0
- package/pi-extension/subagents/completion.ts +114 -0
- package/pi-extension/subagents/herdr.ts +200 -0
- package/pi-extension/subagents/index.ts +2182 -0
- package/pi-extension/subagents/plan-skill.md +203 -0
- package/pi-extension/subagents/plugin/.claude-plugin/plugin.json +5 -0
- package/pi-extension/subagents/plugin/hooks/hooks.json +15 -0
- package/pi-extension/subagents/plugin/hooks/on-stop.sh +68 -0
- package/pi-extension/subagents/session.ts +180 -0
- package/pi-extension/subagents/status.ts +513 -0
- package/pi-extension/subagents/subagent-done.ts +324 -0
- package/pi-extension/subagents/terminal.ts +106 -0
- package/test/integration/agents/test-echo.md +13 -0
- package/test/integration/agents/test-ping.md +11 -0
- package/test/integration/harness.ts +319 -0
- package/test/integration/mux-surface.test.ts +225 -0
- package/test/integration/subagent-lifecycle.test.ts +329 -0
- package/test/system-prompt-mode.test.ts +163 -0
- package/test/test.ts +2190 -0
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
export const SNAPSHOT_STALLED_AFTER_MS = 60_000;
|
|
6
|
+
export const DEFAULT_STATUS_LINE_LIMIT = 4;
|
|
7
|
+
export const MAX_STATUS_NAME_LENGTH = 72;
|
|
8
|
+
export const MAX_STATUS_LINE_LENGTH = 120;
|
|
9
|
+
|
|
10
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
11
|
+
const DEFAULT_STATUS_CONFIG_PATH = join(PACKAGE_ROOT, "config.json");
|
|
12
|
+
const STATUS_CONFIG_EXAMPLE_PATH = join(PACKAGE_ROOT, "config.json.example");
|
|
13
|
+
|
|
14
|
+
export type SubagentStatusKind = "starting" | "active" | "waiting" | "stalled" | "running";
|
|
15
|
+
export type SubagentStatusSource = "pi" | "claude";
|
|
16
|
+
export type SubagentStatusTransition = "stalled" | "recovered" | null;
|
|
17
|
+
export type StatusSnapshotState = "unseen" | "present" | "missing" | "invalid" | "wrong-id";
|
|
18
|
+
export type StatusActivityPhase = "starting" | "active" | "waiting" | "done";
|
|
19
|
+
|
|
20
|
+
export interface StatusConfig {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
lineLimit: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type StatusObservation =
|
|
26
|
+
| {
|
|
27
|
+
snapshot: "present";
|
|
28
|
+
updatedAt: number;
|
|
29
|
+
sequence: number;
|
|
30
|
+
phase: StatusActivityPhase;
|
|
31
|
+
active?: boolean;
|
|
32
|
+
activeScope?: string;
|
|
33
|
+
activeSince?: number;
|
|
34
|
+
waitingSince?: number;
|
|
35
|
+
latestEvent?: string;
|
|
36
|
+
activityLabel?: string;
|
|
37
|
+
}
|
|
38
|
+
| {
|
|
39
|
+
snapshot: "missing" | "invalid" | "wrong-id";
|
|
40
|
+
snapshotError?: string;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export interface SubagentStatusState {
|
|
44
|
+
source: SubagentStatusSource;
|
|
45
|
+
startTimeMs: number;
|
|
46
|
+
firstObservationAtMs: number | null;
|
|
47
|
+
lastActivityAtMs: number | null;
|
|
48
|
+
lastActivitySequence: number | null;
|
|
49
|
+
localOverrideAtMs: number | null;
|
|
50
|
+
localOverrideSequence: number | null;
|
|
51
|
+
activeNow: boolean;
|
|
52
|
+
activeSinceMs: number | null;
|
|
53
|
+
activeScope: string | null;
|
|
54
|
+
waitingSinceMs: number | null;
|
|
55
|
+
phase: StatusActivityPhase | null;
|
|
56
|
+
latestEvent: string | null;
|
|
57
|
+
activityLabel: string | null;
|
|
58
|
+
snapshotState: StatusSnapshotState;
|
|
59
|
+
snapshotProblemSinceMs: number | null;
|
|
60
|
+
snapshotError: string | null;
|
|
61
|
+
currentKind: SubagentStatusKind;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface StatusSnapshot {
|
|
65
|
+
kind: SubagentStatusKind;
|
|
66
|
+
elapsedMs: number;
|
|
67
|
+
elapsedText: string;
|
|
68
|
+
activeSinceMs: number | null;
|
|
69
|
+
activeDurationText: string | null;
|
|
70
|
+
activeScope: string | null;
|
|
71
|
+
waitingSinceMs: number | null;
|
|
72
|
+
waitingDurationText: string | null;
|
|
73
|
+
latestEvent: string | null;
|
|
74
|
+
activityLabel: string | null;
|
|
75
|
+
snapshotState: StatusSnapshotState;
|
|
76
|
+
snapshotError: string | null;
|
|
77
|
+
snapshotProblemText: string | null;
|
|
78
|
+
statusLabel: string | null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CappedStatusLines {
|
|
82
|
+
visibleLines: string[];
|
|
83
|
+
overflow: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function invalidStatusConfig(source: string, message: string): never {
|
|
87
|
+
throw new Error(`Invalid subagent status config in ${source}: ${message}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireObject(value: unknown, source: string, fieldName: string): Record<string, unknown> {
|
|
91
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) {
|
|
92
|
+
invalidStatusConfig(source, `${fieldName} must be an object`);
|
|
93
|
+
}
|
|
94
|
+
return value as Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function requireBoolean(value: unknown, source: string, fieldName: string): boolean {
|
|
98
|
+
if (typeof value !== "boolean") {
|
|
99
|
+
invalidStatusConfig(source, `${fieldName} must be a boolean`);
|
|
100
|
+
}
|
|
101
|
+
return value;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rejectUnsupportedKeys(
|
|
105
|
+
value: Record<string, unknown>,
|
|
106
|
+
allowedKeys: string[],
|
|
107
|
+
source: string,
|
|
108
|
+
fieldName: string,
|
|
109
|
+
): void {
|
|
110
|
+
const unsupportedKeys = Object.keys(value).filter((key) => !allowedKeys.includes(key));
|
|
111
|
+
if (unsupportedKeys.length > 0) {
|
|
112
|
+
invalidStatusConfig(source, `${fieldName} has unsupported key(s): ${unsupportedKeys.join(", ")}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function truncateText(text: string, maxLength: number): string {
|
|
117
|
+
if (text.length <= maxLength) return text;
|
|
118
|
+
if (maxLength <= 1) return text.slice(0, maxLength);
|
|
119
|
+
return `${text.slice(0, maxLength - 1)}…`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function normalizeStatusName(name: string): string {
|
|
123
|
+
const collapsed = name.replace(/\s+/g, " ").trim() || "subagent";
|
|
124
|
+
return truncateText(collapsed, MAX_STATUS_NAME_LENGTH);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function boundStatusLine(line: string): string {
|
|
128
|
+
return truncateText(line.replace(/\s+/g, " ").trim(), MAX_STATUS_LINE_LENGTH);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function snapshotProblemLabel(snapshotState: StatusSnapshotState): string | null {
|
|
132
|
+
if (snapshotState === "wrong-id") return "wrong activity id";
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function activityLabel(snapshot: Pick<StatusSnapshot, "activityLabel" | "activeScope">): string | null {
|
|
137
|
+
return snapshot.activityLabel ?? snapshot.activeScope;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function parseStatusConfig(rawConfig: unknown, source = "config.json"): StatusConfig {
|
|
141
|
+
const config = requireObject(rawConfig, source, "root");
|
|
142
|
+
const status = requireObject(config.status, source, "status");
|
|
143
|
+
rejectUnsupportedKeys(status, ["enabled"], source, "status");
|
|
144
|
+
const enabled = requireBoolean(status.enabled, source, "status.enabled");
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
enabled,
|
|
148
|
+
lineLimit: DEFAULT_STATUS_LINE_LIMIT,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readStatusConfigFile(configPath: string, examplePath: string): { sourcePath: string; rawConfig: string } {
|
|
153
|
+
try {
|
|
154
|
+
return { sourcePath: configPath, rawConfig: readFileSync(configPath, "utf8") };
|
|
155
|
+
} catch (error) {
|
|
156
|
+
const errno = error as NodeJS.ErrnoException;
|
|
157
|
+
if (errno.code !== "ENOENT") throw error;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
return { sourcePath: examplePath, rawConfig: readFileSync(examplePath, "utf8") };
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const errno = error as NodeJS.ErrnoException;
|
|
164
|
+
if (errno.code === "ENOENT") {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`Missing subagent status config. Expected ${configPath} or ${examplePath}.`,
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function loadStatusConfig(
|
|
174
|
+
configPath = DEFAULT_STATUS_CONFIG_PATH,
|
|
175
|
+
examplePath = STATUS_CONFIG_EXAMPLE_PATH,
|
|
176
|
+
): StatusConfig {
|
|
177
|
+
const { sourcePath, rawConfig } = readStatusConfigFile(configPath, examplePath);
|
|
178
|
+
|
|
179
|
+
let parsed: unknown;
|
|
180
|
+
try {
|
|
181
|
+
parsed = JSON.parse(rawConfig) as unknown;
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
184
|
+
throw new Error(`Invalid JSON in subagent config ${sourcePath}: ${detail}`);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return parseStatusConfig(parsed, sourcePath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function formatElapsedDuration(ms: number): string {
|
|
191
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
192
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
193
|
+
|
|
194
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
195
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
196
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
197
|
+
|
|
198
|
+
return `${minutes}m`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createStatusState(params: {
|
|
202
|
+
source: SubagentStatusSource;
|
|
203
|
+
startTimeMs: number;
|
|
204
|
+
}): SubagentStatusState {
|
|
205
|
+
const initialKind = params.source === "claude" ? "running" : "starting";
|
|
206
|
+
return {
|
|
207
|
+
source: params.source,
|
|
208
|
+
startTimeMs: params.startTimeMs,
|
|
209
|
+
firstObservationAtMs: null,
|
|
210
|
+
lastActivityAtMs: null,
|
|
211
|
+
lastActivitySequence: null,
|
|
212
|
+
localOverrideAtMs: null,
|
|
213
|
+
localOverrideSequence: null,
|
|
214
|
+
activeNow: false,
|
|
215
|
+
activeSinceMs: null,
|
|
216
|
+
activeScope: null,
|
|
217
|
+
waitingSinceMs: null,
|
|
218
|
+
phase: null,
|
|
219
|
+
latestEvent: null,
|
|
220
|
+
activityLabel: null,
|
|
221
|
+
snapshotState: params.source === "claude" ? "unseen" : "unseen",
|
|
222
|
+
snapshotProblemSinceMs: null,
|
|
223
|
+
snapshotError: null,
|
|
224
|
+
currentKind: initialKind,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function observeStatus(
|
|
229
|
+
state: SubagentStatusState,
|
|
230
|
+
observation: StatusObservation,
|
|
231
|
+
now: number,
|
|
232
|
+
): SubagentStatusState {
|
|
233
|
+
if (state.source === "claude") return state;
|
|
234
|
+
|
|
235
|
+
if (observation.snapshot !== "present") {
|
|
236
|
+
return {
|
|
237
|
+
...state,
|
|
238
|
+
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
|
239
|
+
snapshotState: observation.snapshot,
|
|
240
|
+
snapshotProblemSinceMs: state.snapshotProblemSinceMs ?? now,
|
|
241
|
+
snapshotError: observation.snapshotError ?? null,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const updatedAt = observation.updatedAt;
|
|
246
|
+
const sequence = observation.sequence;
|
|
247
|
+
const lastActivityAtMs = state.lastActivityAtMs;
|
|
248
|
+
const lastActivitySequence = state.lastActivitySequence;
|
|
249
|
+
const olderThanLastActivity = lastActivityAtMs != null && (
|
|
250
|
+
updatedAt < lastActivityAtMs ||
|
|
251
|
+
(updatedAt === lastActivityAtMs && lastActivitySequence != null && sequence < lastActivitySequence)
|
|
252
|
+
);
|
|
253
|
+
if (olderThanLastActivity) return state;
|
|
254
|
+
|
|
255
|
+
const blockedByLocalOverride = state.localOverrideAtMs != null && (
|
|
256
|
+
updatedAt < state.localOverrideAtMs ||
|
|
257
|
+
(updatedAt === state.localOverrideAtMs && state.localOverrideSequence != null && sequence <= state.localOverrideSequence)
|
|
258
|
+
);
|
|
259
|
+
if (blockedByLocalOverride) return state;
|
|
260
|
+
|
|
261
|
+
const phase = observation.phase;
|
|
262
|
+
const activeNow = phase === "active" || observation.active === true;
|
|
263
|
+
const activeSinceMs = activeNow
|
|
264
|
+
? observation.activeSince ?? state.activeSinceMs ?? updatedAt
|
|
265
|
+
: null;
|
|
266
|
+
const waitingSinceMs = phase === "waiting"
|
|
267
|
+
? observation.waitingSince ?? state.waitingSinceMs ?? updatedAt
|
|
268
|
+
: null;
|
|
269
|
+
|
|
270
|
+
return {
|
|
271
|
+
...state,
|
|
272
|
+
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
|
273
|
+
lastActivityAtMs: updatedAt,
|
|
274
|
+
lastActivitySequence: sequence,
|
|
275
|
+
activeNow,
|
|
276
|
+
activeSinceMs,
|
|
277
|
+
activeScope: activeNow ? observation.activeScope ?? null : null,
|
|
278
|
+
waitingSinceMs,
|
|
279
|
+
phase,
|
|
280
|
+
latestEvent: observation.latestEvent ?? null,
|
|
281
|
+
activityLabel: observation.activityLabel ?? null,
|
|
282
|
+
snapshotState: "present",
|
|
283
|
+
snapshotProblemSinceMs: null,
|
|
284
|
+
snapshotError: null,
|
|
285
|
+
localOverrideAtMs: null,
|
|
286
|
+
localOverrideSequence: null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function forceStatusAfterInterrupt(state: SubagentStatusState, now: number): SubagentStatusState {
|
|
291
|
+
if (state.source === "claude") return state;
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
...state,
|
|
295
|
+
firstObservationAtMs: state.firstObservationAtMs ?? now,
|
|
296
|
+
lastActivityAtMs: now,
|
|
297
|
+
localOverrideAtMs: now,
|
|
298
|
+
localOverrideSequence: state.lastActivitySequence,
|
|
299
|
+
activeNow: false,
|
|
300
|
+
activeSinceMs: null,
|
|
301
|
+
activeScope: null,
|
|
302
|
+
waitingSinceMs: now,
|
|
303
|
+
phase: "waiting",
|
|
304
|
+
latestEvent: "interrupt_requested",
|
|
305
|
+
activityLabel: "interrupted",
|
|
306
|
+
snapshotState: "present",
|
|
307
|
+
snapshotProblemSinceMs: null,
|
|
308
|
+
snapshotError: null,
|
|
309
|
+
currentKind: "waiting",
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function classifyProblemState(state: SubagentStatusState, now: number): Pick<StatusSnapshot, "kind" | "statusLabel"> {
|
|
314
|
+
const problemLabel = snapshotProblemLabel(state.snapshotState);
|
|
315
|
+
const hasValidSnapshot = state.lastActivityAtMs != null;
|
|
316
|
+
|
|
317
|
+
if (!hasValidSnapshot) {
|
|
318
|
+
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
|
|
319
|
+
const elapsedMs = Math.max(0, now - referenceMs);
|
|
320
|
+
return elapsedMs >= SNAPSHOT_STALLED_AFTER_MS
|
|
321
|
+
? { kind: "stalled", statusLabel: problemLabel }
|
|
322
|
+
: { kind: "starting", statusLabel: null };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const problemSinceMs = state.snapshotProblemSinceMs ?? now;
|
|
326
|
+
const problemMs = Math.max(0, now - problemSinceMs);
|
|
327
|
+
if (problemMs >= SNAPSHOT_STALLED_AFTER_MS) return { kind: "stalled", statusLabel: problemLabel };
|
|
328
|
+
|
|
329
|
+
const lastHealthyKind = state.activeNow
|
|
330
|
+
? "active"
|
|
331
|
+
: state.waitingSinceMs != null || state.phase === "done"
|
|
332
|
+
? "waiting"
|
|
333
|
+
: state.currentKind === "stalled"
|
|
334
|
+
? "starting"
|
|
335
|
+
: state.currentKind;
|
|
336
|
+
return { kind: lastHealthyKind, statusLabel: problemLabel };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function classifyStatus(state: SubagentStatusState, now: number): StatusSnapshot {
|
|
340
|
+
const elapsedMs = Math.max(0, now - state.startTimeMs);
|
|
341
|
+
const elapsedText = formatElapsedDuration(elapsedMs);
|
|
342
|
+
|
|
343
|
+
if (state.source === "claude") {
|
|
344
|
+
return {
|
|
345
|
+
kind: "running",
|
|
346
|
+
elapsedMs,
|
|
347
|
+
elapsedText,
|
|
348
|
+
activeSinceMs: null,
|
|
349
|
+
activeDurationText: null,
|
|
350
|
+
activeScope: null,
|
|
351
|
+
waitingSinceMs: null,
|
|
352
|
+
waitingDurationText: null,
|
|
353
|
+
latestEvent: null,
|
|
354
|
+
activityLabel: null,
|
|
355
|
+
snapshotState: state.snapshotState,
|
|
356
|
+
snapshotError: null,
|
|
357
|
+
snapshotProblemText: null,
|
|
358
|
+
statusLabel: null,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
let kind: SubagentStatusKind;
|
|
363
|
+
let statusLabel: string | null = null;
|
|
364
|
+
|
|
365
|
+
if (state.snapshotState === "present") {
|
|
366
|
+
if (state.phase === "active" || state.activeNow) {
|
|
367
|
+
kind = "active";
|
|
368
|
+
} else if (state.phase === "waiting") {
|
|
369
|
+
kind = "waiting";
|
|
370
|
+
} else if (state.phase === "done") {
|
|
371
|
+
kind = "waiting";
|
|
372
|
+
statusLabel = "done";
|
|
373
|
+
} else {
|
|
374
|
+
const referenceMs = state.firstObservationAtMs ?? state.startTimeMs;
|
|
375
|
+
const elapsedSinceObservationMs = Math.max(0, now - referenceMs);
|
|
376
|
+
kind = elapsedSinceObservationMs >= SNAPSHOT_STALLED_AFTER_MS ? "stalled" : "starting";
|
|
377
|
+
statusLabel = null;
|
|
378
|
+
}
|
|
379
|
+
} else {
|
|
380
|
+
const classified = classifyProblemState(state, now);
|
|
381
|
+
kind = classified.kind;
|
|
382
|
+
statusLabel = classified.statusLabel;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const activeDurationText = state.activeSinceMs == null
|
|
386
|
+
? null
|
|
387
|
+
: formatElapsedDuration(now - state.activeSinceMs);
|
|
388
|
+
const waitingDurationText = state.waitingSinceMs == null
|
|
389
|
+
? null
|
|
390
|
+
: formatElapsedDuration(now - state.waitingSinceMs);
|
|
391
|
+
const snapshotProblemText = state.snapshotProblemSinceMs == null
|
|
392
|
+
? null
|
|
393
|
+
: formatElapsedDuration(now - state.snapshotProblemSinceMs);
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
kind,
|
|
397
|
+
elapsedMs,
|
|
398
|
+
elapsedText,
|
|
399
|
+
activeSinceMs: state.activeSinceMs,
|
|
400
|
+
activeDurationText,
|
|
401
|
+
activeScope: state.activeScope,
|
|
402
|
+
waitingSinceMs: state.waitingSinceMs,
|
|
403
|
+
waitingDurationText,
|
|
404
|
+
latestEvent: state.latestEvent,
|
|
405
|
+
activityLabel: state.activityLabel,
|
|
406
|
+
snapshotState: state.snapshotState,
|
|
407
|
+
snapshotError: state.snapshotError,
|
|
408
|
+
snapshotProblemText,
|
|
409
|
+
statusLabel,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function advanceStatusState(
|
|
414
|
+
state: SubagentStatusState,
|
|
415
|
+
now: number,
|
|
416
|
+
): {
|
|
417
|
+
nextState: SubagentStatusState;
|
|
418
|
+
snapshot: StatusSnapshot;
|
|
419
|
+
transition: SubagentStatusTransition;
|
|
420
|
+
} {
|
|
421
|
+
const snapshot = classifyStatus(state, now);
|
|
422
|
+
const transition =
|
|
423
|
+
state.currentKind !== "stalled" && snapshot.kind === "stalled"
|
|
424
|
+
? "stalled"
|
|
425
|
+
: state.currentKind === "stalled" && (snapshot.kind === "active" || snapshot.kind === "waiting")
|
|
426
|
+
? "recovered"
|
|
427
|
+
: null;
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
snapshot,
|
|
431
|
+
transition,
|
|
432
|
+
nextState: {
|
|
433
|
+
...state,
|
|
434
|
+
currentKind: snapshot.kind,
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function formatActiveDetail(snapshot: StatusSnapshot): string {
|
|
440
|
+
const label = activityLabel(snapshot);
|
|
441
|
+
if (!label) return "active";
|
|
442
|
+
const duration = snapshot.activeDurationText ? ` ${snapshot.activeDurationText}` : "";
|
|
443
|
+
return `active (${label}${duration})`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function formatWaitingDetail(snapshot: StatusSnapshot): string {
|
|
447
|
+
const duration = snapshot.waitingDurationText ? ` ${snapshot.waitingDurationText}` : "";
|
|
448
|
+
return `waiting${duration}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function formatStalledDetail(snapshot: StatusSnapshot): string {
|
|
452
|
+
const detail = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
|
|
453
|
+
const duration = snapshot.snapshotProblemText ? ` ${snapshot.snapshotProblemText}` : "";
|
|
454
|
+
return `stalled${duration}${detail}`;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function formatStatusLine(name: string, snapshot: StatusSnapshot): string {
|
|
458
|
+
const boundedName = normalizeStatusName(name);
|
|
459
|
+
|
|
460
|
+
if (snapshot.kind === "starting") {
|
|
461
|
+
const label = snapshot.statusLabel ? ` (${snapshot.statusLabel})` : "";
|
|
462
|
+
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, starting${label}.`);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (snapshot.kind === "running") {
|
|
466
|
+
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}.`);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
if (snapshot.kind === "active") {
|
|
470
|
+
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatActiveDetail(snapshot)}.`);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (snapshot.kind === "waiting") {
|
|
474
|
+
const problem = snapshot.statusLabel && snapshot.statusLabel !== "done"
|
|
475
|
+
? ` (${snapshot.statusLabel})`
|
|
476
|
+
: snapshot.statusLabel === "done"
|
|
477
|
+
? " (done)"
|
|
478
|
+
: "";
|
|
479
|
+
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatWaitingDetail(snapshot)}${problem}.`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, ${formatStalledDetail(snapshot)}.`);
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function formatTransitionLine(
|
|
486
|
+
name: string,
|
|
487
|
+
snapshot: StatusSnapshot,
|
|
488
|
+
transition: Exclude<SubagentStatusTransition, null>,
|
|
489
|
+
): string {
|
|
490
|
+
const boundedName = normalizeStatusName(name);
|
|
491
|
+
|
|
492
|
+
if (transition === "recovered") {
|
|
493
|
+
const detail = snapshot.kind === "waiting" ? formatWaitingDetail(snapshot) : formatActiveDetail(snapshot);
|
|
494
|
+
return boundStatusLine(`${boundedName} running ${snapshot.elapsedText}, recovered; ${detail}.`);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return formatStatusLine(boundedName, snapshot);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export function capStatusLines(lines: string[], lineLimit: number): CappedStatusLines {
|
|
501
|
+
const visibleLines = lines.slice(0, lineLimit);
|
|
502
|
+
return {
|
|
503
|
+
visibleLines,
|
|
504
|
+
overflow: Math.max(0, lines.length - visibleLines.length),
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
export function formatStatusAggregate(lines: string[], lineLimit: number): string {
|
|
509
|
+
const { visibleLines, overflow } = capStatusLines(lines, lineLimit);
|
|
510
|
+
const bulletLines = visibleLines.map((line) => `• ${line}`);
|
|
511
|
+
if (overflow > 0) bulletLines.push(`• +${overflow} more running.`);
|
|
512
|
+
return `Subagent status:\n${bulletLines.join("\n")}`;
|
|
513
|
+
}
|