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,511 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
|
|
4
|
+
export type SubagentActivityPhase = "starting" | "active" | "waiting" | "done";
|
|
5
|
+
export type SubagentActivityScope = "agent" | "turn" | "provider" | "streaming" | "tool";
|
|
6
|
+
|
|
7
|
+
export type SubagentActivityEvent =
|
|
8
|
+
| "session_start"
|
|
9
|
+
| "input"
|
|
10
|
+
| "before_agent_start"
|
|
11
|
+
| "agent_start"
|
|
12
|
+
| "agent_end"
|
|
13
|
+
| "turn_start"
|
|
14
|
+
| "turn_end"
|
|
15
|
+
| "before_provider_request"
|
|
16
|
+
| "after_provider_response"
|
|
17
|
+
| "message_update"
|
|
18
|
+
| "tool_execution_start"
|
|
19
|
+
| "tool_call"
|
|
20
|
+
| "tool_execution_update"
|
|
21
|
+
| "tool_result"
|
|
22
|
+
| "tool_execution_end"
|
|
23
|
+
| "caller_ping"
|
|
24
|
+
| "subagent_done"
|
|
25
|
+
| "session_shutdown";
|
|
26
|
+
|
|
27
|
+
export interface SubagentActivityState {
|
|
28
|
+
version: 1;
|
|
29
|
+
runningChildId: string;
|
|
30
|
+
createdAt: number;
|
|
31
|
+
updatedAt: number;
|
|
32
|
+
sequence: number;
|
|
33
|
+
latestEvent: SubagentActivityEvent;
|
|
34
|
+
phase: SubagentActivityPhase;
|
|
35
|
+
agentActive: boolean;
|
|
36
|
+
turnActive: boolean;
|
|
37
|
+
providerActive: boolean;
|
|
38
|
+
toolActive: boolean;
|
|
39
|
+
activeScope?: SubagentActivityScope;
|
|
40
|
+
activeSince?: number;
|
|
41
|
+
waitingSince?: number;
|
|
42
|
+
turnIndex?: number;
|
|
43
|
+
messageEventType?: string;
|
|
44
|
+
toolCallId?: string;
|
|
45
|
+
toolName?: string;
|
|
46
|
+
toolStartedAt?: number;
|
|
47
|
+
toolEndedAt?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type ActivityReadResult =
|
|
51
|
+
| { ok: true; activity: SubagentActivityState }
|
|
52
|
+
| { ok: false; reason: "missing" | "invalid" | "wrong-id"; error?: string };
|
|
53
|
+
|
|
54
|
+
export type SubagentShutdownReason = "quit" | "reload" | "new" | "resume" | "fork";
|
|
55
|
+
|
|
56
|
+
export interface SubagentActivityRecorder {
|
|
57
|
+
sessionStart(): void;
|
|
58
|
+
input(): void;
|
|
59
|
+
beforeAgentStart(): void;
|
|
60
|
+
agentStart(): void;
|
|
61
|
+
agentEndWaiting(): void;
|
|
62
|
+
agentEndDone(): void;
|
|
63
|
+
turnStart(turnIndex?: number): void;
|
|
64
|
+
turnEnd(turnIndex?: number): void;
|
|
65
|
+
beforeProviderRequest(): void;
|
|
66
|
+
afterProviderResponse(): void;
|
|
67
|
+
messageUpdate(messageEventType?: string): void;
|
|
68
|
+
toolExecutionStart(toolCallId?: string, toolName?: string): void;
|
|
69
|
+
toolCall(toolCallId?: string, toolName?: string): void;
|
|
70
|
+
toolExecutionUpdate(toolCallId?: string, toolName?: string): void;
|
|
71
|
+
toolResult(toolCallId?: string, toolName?: string): void;
|
|
72
|
+
toolExecutionEnd(toolCallId?: string, toolName?: string): void;
|
|
73
|
+
callerPing(): void;
|
|
74
|
+
subagentDone(): void;
|
|
75
|
+
sessionShutdown(reason: SubagentShutdownReason): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const ACTIVITY_UPDATE_THROTTLE_MS = 500;
|
|
79
|
+
const MAX_WRITE_FAILURES = 3;
|
|
80
|
+
const KNOWN_PHASES = new Set<SubagentActivityPhase>(["starting", "active", "waiting", "done"]);
|
|
81
|
+
const KNOWN_SCOPES = new Set<SubagentActivityScope>(["agent", "turn", "provider", "streaming", "tool"]);
|
|
82
|
+
const KNOWN_EVENTS = new Set<SubagentActivityEvent>([
|
|
83
|
+
"session_start",
|
|
84
|
+
"input",
|
|
85
|
+
"before_agent_start",
|
|
86
|
+
"agent_start",
|
|
87
|
+
"agent_end",
|
|
88
|
+
"turn_start",
|
|
89
|
+
"turn_end",
|
|
90
|
+
"before_provider_request",
|
|
91
|
+
"after_provider_response",
|
|
92
|
+
"message_update",
|
|
93
|
+
"tool_execution_start",
|
|
94
|
+
"tool_call",
|
|
95
|
+
"tool_execution_update",
|
|
96
|
+
"tool_result",
|
|
97
|
+
"tool_execution_end",
|
|
98
|
+
"caller_ping",
|
|
99
|
+
"subagent_done",
|
|
100
|
+
"session_shutdown",
|
|
101
|
+
]);
|
|
102
|
+
const MAX_ACTIVITY_STRING_LENGTH = 200;
|
|
103
|
+
|
|
104
|
+
export function getSubagentActivityFile(artifactDir: string, runningChildId: string): string {
|
|
105
|
+
return join(artifactDir, "subagent-activity", `${runningChildId}.json`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function requireObject(value: unknown): Record<string, unknown> | null {
|
|
109
|
+
if (value == null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
110
|
+
return value as Record<string, unknown>;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validateFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
|
|
114
|
+
return Number.isFinite(object[fieldName]) ? null : `${fieldName} must be finite`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function validateOptionalFiniteNumber(object: Record<string, unknown>, fieldName: string): string | null {
|
|
118
|
+
const value = object[fieldName];
|
|
119
|
+
return value == null || Number.isFinite(value) ? null : `${fieldName} must be finite when present`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function validateInteger(object: Record<string, unknown>, fieldName: string): string | null {
|
|
123
|
+
return Number.isInteger(object[fieldName]) ? null : `${fieldName} must be an integer`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validateOptionalInteger(object: Record<string, unknown>, fieldName: string): string | null {
|
|
127
|
+
const value = object[fieldName];
|
|
128
|
+
return value == null || Number.isInteger(value) ? null : `${fieldName} must be an integer when present`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function validateBoolean(object: Record<string, unknown>, fieldName: string): string | null {
|
|
132
|
+
return typeof object[fieldName] === "boolean" ? null : `${fieldName} must be a boolean`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function validateOptionalActivityString(object: Record<string, unknown>, fieldName: string): string | null {
|
|
136
|
+
const value = object[fieldName];
|
|
137
|
+
if (value == null) return null;
|
|
138
|
+
if (typeof value !== "string") return `${fieldName} must be a string when present`;
|
|
139
|
+
if (/\r|\n/.test(value)) return `${fieldName} must not contain newlines`;
|
|
140
|
+
return value.length <= MAX_ACTIVITY_STRING_LENGTH ? null : `${fieldName} is too long`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function invalidActivity(error: string): ActivityReadResult {
|
|
144
|
+
return { ok: false, reason: "invalid", error };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateActivity(value: unknown, expectedRunningChildId: string): ActivityReadResult {
|
|
148
|
+
const object = requireObject(value);
|
|
149
|
+
if (!object) return invalidActivity("activity must be an object");
|
|
150
|
+
if (object.version !== 1) return invalidActivity("unsupported activity version");
|
|
151
|
+
if (typeof object.runningChildId !== "string") return invalidActivity("runningChildId must be a string");
|
|
152
|
+
if (object.runningChildId !== expectedRunningChildId) return { ok: false, reason: "wrong-id" };
|
|
153
|
+
if (typeof object.latestEvent !== "string" || !KNOWN_EVENTS.has(object.latestEvent as SubagentActivityEvent)) {
|
|
154
|
+
return invalidActivity("unknown latestEvent");
|
|
155
|
+
}
|
|
156
|
+
if (typeof object.phase !== "string" || !KNOWN_PHASES.has(object.phase as SubagentActivityPhase)) {
|
|
157
|
+
return invalidActivity("unknown activity phase");
|
|
158
|
+
}
|
|
159
|
+
if (
|
|
160
|
+
object.activeScope != null &&
|
|
161
|
+
(typeof object.activeScope !== "string" || !KNOWN_SCOPES.has(object.activeScope as SubagentActivityScope))
|
|
162
|
+
) {
|
|
163
|
+
return invalidActivity("unknown activeScope");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const validationError = [
|
|
167
|
+
validateFiniteNumber(object, "createdAt"),
|
|
168
|
+
validateFiniteNumber(object, "updatedAt"),
|
|
169
|
+
validateInteger(object, "sequence"),
|
|
170
|
+
validateBoolean(object, "agentActive"),
|
|
171
|
+
validateBoolean(object, "turnActive"),
|
|
172
|
+
validateBoolean(object, "providerActive"),
|
|
173
|
+
validateBoolean(object, "toolActive"),
|
|
174
|
+
validateOptionalFiniteNumber(object, "activeSince"),
|
|
175
|
+
validateOptionalFiniteNumber(object, "waitingSince"),
|
|
176
|
+
validateOptionalInteger(object, "turnIndex"),
|
|
177
|
+
validateOptionalFiniteNumber(object, "toolStartedAt"),
|
|
178
|
+
validateOptionalFiniteNumber(object, "toolEndedAt"),
|
|
179
|
+
validateOptionalActivityString(object, "messageEventType"),
|
|
180
|
+
validateOptionalActivityString(object, "toolCallId"),
|
|
181
|
+
validateOptionalActivityString(object, "toolName"),
|
|
182
|
+
].find((error) => error != null);
|
|
183
|
+
if (validationError) return invalidActivity(validationError);
|
|
184
|
+
|
|
185
|
+
return { ok: true, activity: object as unknown as SubagentActivityState };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function readSubagentActivityFile(
|
|
189
|
+
activityFile: string,
|
|
190
|
+
expectedRunningChildId: string,
|
|
191
|
+
): ActivityReadResult {
|
|
192
|
+
if (!existsSync(activityFile)) return { ok: false, reason: "missing" };
|
|
193
|
+
|
|
194
|
+
let parsed: unknown;
|
|
195
|
+
try {
|
|
196
|
+
parsed = JSON.parse(readFileSync(activityFile, "utf8"));
|
|
197
|
+
} catch (error) {
|
|
198
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
199
|
+
return { ok: false, reason: "invalid", error: message };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return validateActivity(parsed, expectedRunningChildId);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function writeSubagentActivityFile(activityFile: string, activity: SubagentActivityState): void {
|
|
206
|
+
const dir = dirname(activityFile);
|
|
207
|
+
mkdirSync(dir, { recursive: true });
|
|
208
|
+
const tempFile = join(dir, `${activity.runningChildId}.json.${process.pid}.${activity.sequence}.tmp`);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
writeFileSync(tempFile, `${JSON.stringify(activity)}\n`, "utf8");
|
|
212
|
+
renameSync(tempFile, activityFile);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
try {
|
|
215
|
+
unlinkSync(tempFile);
|
|
216
|
+
} catch (cleanupError) {
|
|
217
|
+
// Temp cleanup is best effort; preserve the original write/rename failure
|
|
218
|
+
void cleanupError;
|
|
219
|
+
}
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function createNoopRecorder(): SubagentActivityRecorder {
|
|
225
|
+
return {
|
|
226
|
+
sessionStart() {},
|
|
227
|
+
input() {},
|
|
228
|
+
beforeAgentStart() {},
|
|
229
|
+
agentStart() {},
|
|
230
|
+
agentEndWaiting() {},
|
|
231
|
+
agentEndDone() {},
|
|
232
|
+
turnStart() {},
|
|
233
|
+
turnEnd() {},
|
|
234
|
+
beforeProviderRequest() {},
|
|
235
|
+
afterProviderResponse() {},
|
|
236
|
+
messageUpdate() {},
|
|
237
|
+
toolExecutionStart() {},
|
|
238
|
+
toolCall() {},
|
|
239
|
+
toolExecutionUpdate() {},
|
|
240
|
+
toolResult() {},
|
|
241
|
+
toolExecutionEnd() {},
|
|
242
|
+
callerPing() {},
|
|
243
|
+
subagentDone() {},
|
|
244
|
+
sessionShutdown() {},
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function clearActiveState(activity: SubagentActivityState): void {
|
|
249
|
+
activity.agentActive = false;
|
|
250
|
+
activity.turnActive = false;
|
|
251
|
+
activity.providerActive = false;
|
|
252
|
+
activity.toolActive = false;
|
|
253
|
+
delete activity.activeScope;
|
|
254
|
+
delete activity.activeSince;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function refreshActiveScope(activity: SubagentActivityState): void {
|
|
258
|
+
if (activity.toolActive) {
|
|
259
|
+
activity.phase = "active";
|
|
260
|
+
activity.activeScope = "tool";
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
if (activity.providerActive) {
|
|
264
|
+
activity.phase = "active";
|
|
265
|
+
activity.activeScope = "provider";
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (activity.turnActive) {
|
|
269
|
+
activity.phase = "active";
|
|
270
|
+
activity.activeScope = "turn";
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (activity.agentActive) {
|
|
274
|
+
activity.phase = "active";
|
|
275
|
+
activity.activeScope = "agent";
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
delete activity.activeScope;
|
|
279
|
+
delete activity.activeSince;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function markActive(
|
|
283
|
+
activity: SubagentActivityState,
|
|
284
|
+
scope: SubagentActivityScope,
|
|
285
|
+
now: number,
|
|
286
|
+
resetActiveSince = false,
|
|
287
|
+
): void {
|
|
288
|
+
activity.phase = "active";
|
|
289
|
+
activity.activeScope = scope;
|
|
290
|
+
if (activity.activeSince == null || resetActiveSince) activity.activeSince = now;
|
|
291
|
+
delete activity.waitingSince;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function createSubagentActivityRecorder(params: {
|
|
295
|
+
runningChildId?: string;
|
|
296
|
+
activityFile?: string;
|
|
297
|
+
now?: () => number;
|
|
298
|
+
}): SubagentActivityRecorder {
|
|
299
|
+
const runningChildId = params.runningChildId?.trim();
|
|
300
|
+
const activityFile = params.activityFile?.trim();
|
|
301
|
+
if (!runningChildId || !activityFile) return createNoopRecorder();
|
|
302
|
+
|
|
303
|
+
const now = params.now ?? (() => Date.now());
|
|
304
|
+
const createdAt = now();
|
|
305
|
+
const activity: SubagentActivityState = {
|
|
306
|
+
version: 1,
|
|
307
|
+
runningChildId,
|
|
308
|
+
createdAt,
|
|
309
|
+
updatedAt: createdAt,
|
|
310
|
+
sequence: 0,
|
|
311
|
+
latestEvent: "session_start",
|
|
312
|
+
phase: "starting",
|
|
313
|
+
agentActive: false,
|
|
314
|
+
turnActive: false,
|
|
315
|
+
providerActive: false,
|
|
316
|
+
toolActive: false,
|
|
317
|
+
};
|
|
318
|
+
|
|
319
|
+
let disabled = false;
|
|
320
|
+
let failureCount = 0;
|
|
321
|
+
let lastFlushAt = 0;
|
|
322
|
+
let pendingFlush: ReturnType<typeof setTimeout> | null = null;
|
|
323
|
+
|
|
324
|
+
function clearPendingFlush(): void {
|
|
325
|
+
if (!pendingFlush) return;
|
|
326
|
+
clearTimeout(pendingFlush);
|
|
327
|
+
pendingFlush = null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function disable(): void {
|
|
331
|
+
disabled = true;
|
|
332
|
+
clearPendingFlush();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function flushNow(): void {
|
|
336
|
+
if (disabled) return;
|
|
337
|
+
try {
|
|
338
|
+
writeSubagentActivityFile(activityFile, activity);
|
|
339
|
+
lastFlushAt = now();
|
|
340
|
+
failureCount = 0;
|
|
341
|
+
} catch {
|
|
342
|
+
failureCount += 1;
|
|
343
|
+
if (failureCount >= MAX_WRITE_FAILURES) disable();
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function scheduleFlush(): void {
|
|
348
|
+
if (disabled || pendingFlush) return;
|
|
349
|
+
|
|
350
|
+
const remainingMs = Math.max(0, ACTIVITY_UPDATE_THROTTLE_MS - (now() - lastFlushAt));
|
|
351
|
+
if (remainingMs === 0) {
|
|
352
|
+
flushNow();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
pendingFlush = setTimeout(() => {
|
|
357
|
+
pendingFlush = null;
|
|
358
|
+
flushNow();
|
|
359
|
+
}, remainingMs);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function record(
|
|
363
|
+
latestEvent: SubagentActivityEvent,
|
|
364
|
+
update: (current: SubagentActivityState, now: number) => void,
|
|
365
|
+
flush: "immediate" | "throttled",
|
|
366
|
+
): void {
|
|
367
|
+
if (disabled) return;
|
|
368
|
+
if (flush === "immediate") clearPendingFlush();
|
|
369
|
+
|
|
370
|
+
const observedAt = now();
|
|
371
|
+
activity.latestEvent = latestEvent;
|
|
372
|
+
activity.updatedAt = observedAt;
|
|
373
|
+
activity.sequence += 1;
|
|
374
|
+
update(activity, observedAt);
|
|
375
|
+
|
|
376
|
+
if (flush === "immediate") flushNow();
|
|
377
|
+
else scheduleFlush();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function markDone(latestEvent: SubagentActivityEvent): void {
|
|
381
|
+
record(latestEvent, (current) => {
|
|
382
|
+
current.phase = "done";
|
|
383
|
+
clearActiveState(current);
|
|
384
|
+
delete current.waitingSince;
|
|
385
|
+
}, "immediate");
|
|
386
|
+
disable();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return {
|
|
390
|
+
sessionStart() {
|
|
391
|
+
record("session_start", (current) => {
|
|
392
|
+
current.phase = "starting";
|
|
393
|
+
clearActiveState(current);
|
|
394
|
+
delete current.waitingSince;
|
|
395
|
+
}, "immediate");
|
|
396
|
+
},
|
|
397
|
+
input() {
|
|
398
|
+
record("input", () => {}, "immediate");
|
|
399
|
+
},
|
|
400
|
+
beforeAgentStart() {
|
|
401
|
+
record("before_agent_start", (current, observedAt) => {
|
|
402
|
+
current.agentActive = true;
|
|
403
|
+
markActive(current, "agent", observedAt);
|
|
404
|
+
}, "immediate");
|
|
405
|
+
},
|
|
406
|
+
agentStart() {
|
|
407
|
+
record("agent_start", (current, observedAt) => {
|
|
408
|
+
current.agentActive = true;
|
|
409
|
+
markActive(current, "agent", observedAt);
|
|
410
|
+
}, "immediate");
|
|
411
|
+
},
|
|
412
|
+
agentEndWaiting() {
|
|
413
|
+
record("agent_end", (current, observedAt) => {
|
|
414
|
+
clearActiveState(current);
|
|
415
|
+
current.phase = "waiting";
|
|
416
|
+
current.waitingSince = observedAt;
|
|
417
|
+
}, "immediate");
|
|
418
|
+
},
|
|
419
|
+
agentEndDone() {
|
|
420
|
+
markDone("agent_end");
|
|
421
|
+
},
|
|
422
|
+
turnStart(turnIndex) {
|
|
423
|
+
record("turn_start", (current, observedAt) => {
|
|
424
|
+
current.agentActive = true;
|
|
425
|
+
current.turnActive = true;
|
|
426
|
+
if (turnIndex != null) current.turnIndex = turnIndex;
|
|
427
|
+
markActive(current, current.toolActive || current.providerActive ? current.activeScope ?? "turn" : "turn", observedAt);
|
|
428
|
+
}, "immediate");
|
|
429
|
+
},
|
|
430
|
+
turnEnd(turnIndex) {
|
|
431
|
+
record("turn_end", (current) => {
|
|
432
|
+
current.turnActive = false;
|
|
433
|
+
current.providerActive = false;
|
|
434
|
+
current.toolActive = false;
|
|
435
|
+
if (turnIndex != null) current.turnIndex = turnIndex;
|
|
436
|
+
refreshActiveScope(current);
|
|
437
|
+
}, "immediate");
|
|
438
|
+
},
|
|
439
|
+
beforeProviderRequest() {
|
|
440
|
+
record("before_provider_request", (current, observedAt) => {
|
|
441
|
+
current.providerActive = true;
|
|
442
|
+
markActive(current, "provider", observedAt, true);
|
|
443
|
+
}, "immediate");
|
|
444
|
+
},
|
|
445
|
+
afterProviderResponse() {
|
|
446
|
+
record("after_provider_response", (current) => {
|
|
447
|
+
current.providerActive = false;
|
|
448
|
+
refreshActiveScope(current);
|
|
449
|
+
}, "immediate");
|
|
450
|
+
},
|
|
451
|
+
messageUpdate(messageEventType) {
|
|
452
|
+
record("message_update", (current, observedAt) => {
|
|
453
|
+
current.agentActive = true;
|
|
454
|
+
current.turnActive = true;
|
|
455
|
+
current.messageEventType = messageEventType;
|
|
456
|
+
if (!current.toolActive) markActive(current, "streaming", observedAt);
|
|
457
|
+
}, "throttled");
|
|
458
|
+
},
|
|
459
|
+
toolExecutionStart(toolCallId, toolName) {
|
|
460
|
+
record("tool_execution_start", (current, observedAt) => {
|
|
461
|
+
current.toolActive = true;
|
|
462
|
+
current.toolCallId = toolCallId;
|
|
463
|
+
current.toolName = toolName;
|
|
464
|
+
current.toolStartedAt = observedAt;
|
|
465
|
+
markActive(current, "tool", observedAt, true);
|
|
466
|
+
}, "immediate");
|
|
467
|
+
},
|
|
468
|
+
toolCall(toolCallId, toolName) {
|
|
469
|
+
record("tool_call", (current, observedAt) => {
|
|
470
|
+
current.toolActive = true;
|
|
471
|
+
current.toolCallId = toolCallId ?? current.toolCallId;
|
|
472
|
+
current.toolName = toolName ?? current.toolName;
|
|
473
|
+
markActive(current, "tool", observedAt);
|
|
474
|
+
}, "immediate");
|
|
475
|
+
},
|
|
476
|
+
toolExecutionUpdate(toolCallId, toolName) {
|
|
477
|
+
record("tool_execution_update", (current, observedAt) => {
|
|
478
|
+
current.toolActive = true;
|
|
479
|
+
current.toolCallId = toolCallId ?? current.toolCallId;
|
|
480
|
+
current.toolName = toolName ?? current.toolName;
|
|
481
|
+
markActive(current, "tool", observedAt);
|
|
482
|
+
}, "throttled");
|
|
483
|
+
},
|
|
484
|
+
toolResult(toolCallId, toolName) {
|
|
485
|
+
record("tool_result", (current) => {
|
|
486
|
+
current.toolCallId = toolCallId ?? current.toolCallId;
|
|
487
|
+
current.toolName = toolName ?? current.toolName;
|
|
488
|
+
refreshActiveScope(current);
|
|
489
|
+
}, "immediate");
|
|
490
|
+
},
|
|
491
|
+
toolExecutionEnd(toolCallId, toolName) {
|
|
492
|
+
record("tool_execution_end", (current, observedAt) => {
|
|
493
|
+
current.toolActive = false;
|
|
494
|
+
current.toolCallId = toolCallId ?? current.toolCallId;
|
|
495
|
+
current.toolName = toolName ?? current.toolName;
|
|
496
|
+
current.toolEndedAt = observedAt;
|
|
497
|
+
refreshActiveScope(current);
|
|
498
|
+
}, "immediate");
|
|
499
|
+
},
|
|
500
|
+
callerPing() {
|
|
501
|
+
markDone("caller_ping");
|
|
502
|
+
},
|
|
503
|
+
subagentDone() {
|
|
504
|
+
markDone("subagent_done");
|
|
505
|
+
},
|
|
506
|
+
sessionShutdown(reason) {
|
|
507
|
+
if (reason === "quit") markDone("session_shutdown");
|
|
508
|
+
else disable();
|
|
509
|
+
},
|
|
510
|
+
};
|
|
511
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const ABORT_MESSAGE = "Aborted while waiting for subagent to finish";
|
|
4
|
+
const TERMINAL_SENTINEL = /__SUBAGENT_DONE_(\d+)__/;
|
|
5
|
+
|
|
6
|
+
export interface CompletionResult {
|
|
7
|
+
reason: "done" | "ping" | "sentinel" | "error";
|
|
8
|
+
exitCode: number;
|
|
9
|
+
ping?: { name: string; message: string };
|
|
10
|
+
errorMessage?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CompletionOptions {
|
|
14
|
+
intervalMs: number;
|
|
15
|
+
readTerminalTail: () => Promise<string>;
|
|
16
|
+
sessionFile?: string;
|
|
17
|
+
sentinelFile?: string;
|
|
18
|
+
onTick?: (elapsedSeconds: number) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function interpretExitSidecar(data: unknown): CompletionResult {
|
|
22
|
+
const payload = data as {
|
|
23
|
+
type?: unknown;
|
|
24
|
+
name?: unknown;
|
|
25
|
+
message?: unknown;
|
|
26
|
+
errorMessage?: unknown;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
if (payload?.type === "ping") {
|
|
30
|
+
return {
|
|
31
|
+
reason: "ping",
|
|
32
|
+
exitCode: 0,
|
|
33
|
+
ping: {
|
|
34
|
+
name: typeof payload.name === "string" ? payload.name : "subagent",
|
|
35
|
+
message: typeof payload.message === "string" ? payload.message : "",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (payload?.type === "error") {
|
|
41
|
+
const errorMessage =
|
|
42
|
+
typeof payload.errorMessage === "string" && payload.errorMessage.trim()
|
|
43
|
+
? payload.errorMessage
|
|
44
|
+
: "Subagent exited with stopReason=error (no errorMessage in sidecar).";
|
|
45
|
+
return { reason: "error", exitCode: 1, errorMessage };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { reason: "done", exitCode: 0 };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function consumeExitSidecar(sessionFile: string | undefined): CompletionResult | null {
|
|
52
|
+
if (!sessionFile) return null;
|
|
53
|
+
|
|
54
|
+
const exitFile = `${sessionFile}.exit`;
|
|
55
|
+
if (!existsSync(exitFile)) return null;
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
const result = interpretExitSidecar(JSON.parse(readFileSync(exitFile, "utf8")));
|
|
59
|
+
rmSync(exitFile, { force: true });
|
|
60
|
+
return result;
|
|
61
|
+
} catch {
|
|
62
|
+
// The child may still be writing the file. Retry on the next polling cycle.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function terminalExitCode(screen: string): number | null {
|
|
68
|
+
const match = screen.match(TERMINAL_SENTINEL);
|
|
69
|
+
return match ? Number.parseInt(match[1], 10) : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function abortableDelay(milliseconds: number, signal: AbortSignal): Promise<void> {
|
|
73
|
+
if (signal.aborted) return Promise.reject(new Error(ABORT_MESSAGE));
|
|
74
|
+
|
|
75
|
+
return new Promise<void>((resolve, reject) => {
|
|
76
|
+
const onAbort = () => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
reject(new Error(ABORT_MESSAGE));
|
|
79
|
+
};
|
|
80
|
+
const timer = setTimeout(() => {
|
|
81
|
+
signal.removeEventListener("abort", onAbort);
|
|
82
|
+
resolve();
|
|
83
|
+
}, milliseconds);
|
|
84
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export async function waitForCompletion(
|
|
89
|
+
signal: AbortSignal,
|
|
90
|
+
options: CompletionOptions,
|
|
91
|
+
): Promise<CompletionResult> {
|
|
92
|
+
const startedAt = Date.now();
|
|
93
|
+
|
|
94
|
+
for (;;) {
|
|
95
|
+
if (signal.aborted) throw new Error(ABORT_MESSAGE);
|
|
96
|
+
|
|
97
|
+
const sidecarResult = consumeExitSidecar(options.sessionFile);
|
|
98
|
+
if (sidecarResult) return sidecarResult;
|
|
99
|
+
|
|
100
|
+
if (options.sentinelFile && existsSync(options.sentinelFile)) {
|
|
101
|
+
return { reason: "sentinel", exitCode: 0 };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const exitCode = terminalExitCode(await options.readTerminalTail());
|
|
106
|
+
if (exitCode !== null) return { reason: "sentinel", exitCode };
|
|
107
|
+
} catch {
|
|
108
|
+
// Pane reads can fail transiently while herdr updates or closes a pane.
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
options.onTick?.(Math.floor((Date.now() - startedAt) / 1000));
|
|
112
|
+
await abortableDelay(options.intervalMs, signal);
|
|
113
|
+
}
|
|
114
|
+
}
|