pi-herdr-agents 0.0.1
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/AGENTS.md +116 -0
- package/CONTEXT.md +159 -0
- package/LICENSE +21 -0
- package/README.md +874 -0
- package/RELEASING.md +139 -0
- package/agents/adversarial-reviewer.md +80 -0
- package/agents/claude-reviewer.md +23 -0
- package/agents/planner.md +539 -0
- package/agents/poteto.md +32 -0
- package/agents/reviewer.md +164 -0
- package/agents/scout.md +106 -0
- package/agents/visual-tester.md +224 -0
- package/agents/worker.md +132 -0
- package/config.json.example +8 -0
- package/docs/README.md +42 -0
- package/docs/adr/0001-btw-ephemeral-side-questions.md +142 -0
- package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +265 -0
- package/docs/adr/0003-installable-role-packs.md +135 -0
- package/docs/adr/0004-require-active-user-approval-for-workflow-execution.md +17 -0
- package/docs/adr/0005-parent-owns-workflow-script-authority.md +17 -0
- package/docs/adr/0006-limit-v1-execution-effects-to-isolated-worktrees.md +18 -0
- package/docs/adr/0007-require-fresh-review-for-workflow-scripts.md +19 -0
- package/docs/orchestrated-review-workflow-plan.md +479 -0
- package/docs/research/pdw-architecture-assessment.md +525 -0
- package/docs/research/pi-workflows-sol-advisor.md +255 -0
- package/docs/research/worktree-subagent-orchestration.md +317 -0
- package/docs/worktree-subagents.md +196 -0
- package/examples/role-pack/extension.ts +18 -0
- package/examples/role-pack/package.json +16 -0
- package/examples/role-pack/roles/example-reviewer.md +12 -0
- package/package.json +58 -0
- package/pi-extension/subagents/activity.ts +511 -0
- package/pi-extension/subagents/completion.ts +177 -0
- package/pi-extension/subagents/herdr.ts +541 -0
- package/pi-extension/subagents/index.ts +4730 -0
- package/pi-extension/subagents/lifecycle.ts +477 -0
- package/pi-extension/subagents/model-config.ts +95 -0
- package/pi-extension/subagents/plan-skill.md +262 -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/runtime-routing.ts +313 -0
- package/pi-extension/subagents/session.ts +216 -0
- package/pi-extension/subagents/status.ts +513 -0
- package/pi-extension/subagents/subagent-done.ts +326 -0
- package/pi-extension/subagents/terminal.ts +163 -0
- package/pi-extension/subagents/workflow-worker.js +56 -0
- package/pi-extension/subagents/workflow.ts +1210 -0
- package/skills/orchestrate/SKILL.md +184 -0
|
@@ -0,0 +1,477 @@
|
|
|
1
|
+
import type { ActivityReadResult, SubagentActivityScope } from "./activity.ts";
|
|
2
|
+
import type { CompletionResult } from "./completion.ts";
|
|
3
|
+
|
|
4
|
+
export type HerdrAgentStatus =
|
|
5
|
+
| "idle"
|
|
6
|
+
| "working"
|
|
7
|
+
| "blocked"
|
|
8
|
+
| "done"
|
|
9
|
+
| "unknown";
|
|
10
|
+
|
|
11
|
+
export type PaneInspection =
|
|
12
|
+
| {
|
|
13
|
+
kind: "present";
|
|
14
|
+
agent?: string;
|
|
15
|
+
agentStatus: HerdrAgentStatus;
|
|
16
|
+
observedAt: number;
|
|
17
|
+
}
|
|
18
|
+
| { kind: "missing"; error?: string }
|
|
19
|
+
| { kind: "unavailable"; error?: string };
|
|
20
|
+
|
|
21
|
+
export type ProcessState =
|
|
22
|
+
| { kind: "starting"; startedAt: number }
|
|
23
|
+
| { kind: "running"; startedAt: number; confirmedAt: number }
|
|
24
|
+
| { kind: "finalizing"; startedAt: number; detectedAt: number; completion: CompletionResult }
|
|
25
|
+
| { kind: "completed"; startedAt: number; detectedAt: number; completedAt: number; completion: CompletionResult }
|
|
26
|
+
| { kind: "failed"; startedAt: number; detectedAt: number; completedAt: number; error: string; exitCode?: number };
|
|
27
|
+
|
|
28
|
+
export type ActivityDetail =
|
|
29
|
+
| { kind: "none"; observedAt: number }
|
|
30
|
+
| { kind: "scope"; scope: SubagentActivityScope; label?: string; since: number; observedAt: number; sequence: number };
|
|
31
|
+
|
|
32
|
+
export type TurnState =
|
|
33
|
+
| { kind: "unknown" }
|
|
34
|
+
| { kind: "starting"; observedAt: number }
|
|
35
|
+
| { kind: "active"; startedAt: number; source: "activity" | "herdr" | "fallback"; activity?: ActivityDetail }
|
|
36
|
+
| { kind: "blocked"; startedAt: number }
|
|
37
|
+
| { kind: "waiting"; startedAt: number }
|
|
38
|
+
| { kind: "interrupted"; requestedAt: number; previousActivitySequence: number | null };
|
|
39
|
+
|
|
40
|
+
export type ActivityHealth =
|
|
41
|
+
| { kind: "unseen" }
|
|
42
|
+
| { kind: "healthy"; observedAt: number }
|
|
43
|
+
| { kind: "problem"; reason: "missing" | "invalid" | "wrong-id"; since: number; error?: string };
|
|
44
|
+
|
|
45
|
+
export type PaneObservation =
|
|
46
|
+
| { kind: "unknown" }
|
|
47
|
+
| { kind: "present"; observedAt: number; agentStatus: HerdrAgentStatus }
|
|
48
|
+
| { kind: "read-error"; firstFailedAt: number; lastFailedAt: number; consecutiveFailures: number; error?: string }
|
|
49
|
+
| { kind: "missing"; detectedAt: number; error?: string };
|
|
50
|
+
|
|
51
|
+
export type CompletionDelivery = "pending" | "delivered" | "suppressed";
|
|
52
|
+
|
|
53
|
+
export interface SubagentLifecycle {
|
|
54
|
+
process: ProcessState;
|
|
55
|
+
turn: TurnState;
|
|
56
|
+
activityHealth: ActivityHealth;
|
|
57
|
+
/** Latest optional Pi detail, independent of Herdr coarse turn state. */
|
|
58
|
+
activityDetail: ActivityDetail | null;
|
|
59
|
+
pane: PaneObservation;
|
|
60
|
+
/** Durable across unavailable/missing observations. */
|
|
61
|
+
hasWorked: boolean;
|
|
62
|
+
lastActivitySequence: number | null;
|
|
63
|
+
delivery: CompletionDelivery;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface LifecycleProjection {
|
|
67
|
+
kind: "starting" | "running" | "active" | "blocked" | "waiting" | "interrupted" | "stalled" | "finalizing" | "completed" | "failed";
|
|
68
|
+
label?: string;
|
|
69
|
+
runtimeEndedAt?: number;
|
|
70
|
+
stateDurationSince?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createLifecycle(startedAt: number): SubagentLifecycle {
|
|
74
|
+
return {
|
|
75
|
+
process: { kind: "starting", startedAt },
|
|
76
|
+
turn: { kind: "unknown" },
|
|
77
|
+
activityHealth: { kind: "unseen" },
|
|
78
|
+
activityDetail: null,
|
|
79
|
+
pane: { kind: "unknown" },
|
|
80
|
+
hasWorked: false,
|
|
81
|
+
lastActivitySequence: null,
|
|
82
|
+
delivery: "pending",
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isTerminal(process: ProcessState): boolean {
|
|
87
|
+
return process.kind === "completed" || process.kind === "failed";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function startedAt(process: ProcessState): number {
|
|
91
|
+
return process.startedAt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function observePaneInspection(
|
|
95
|
+
lifecycle: SubagentLifecycle,
|
|
96
|
+
inspection: PaneInspection,
|
|
97
|
+
observedAt: number,
|
|
98
|
+
): SubagentLifecycle {
|
|
99
|
+
if (isTerminal(lifecycle.process)) return lifecycle;
|
|
100
|
+
if (lifecycle.process.kind === "finalizing") return lifecycle;
|
|
101
|
+
|
|
102
|
+
if (inspection.kind === "unavailable") {
|
|
103
|
+
const previous = lifecycle.pane.kind === "read-error" ? lifecycle.pane : null;
|
|
104
|
+
return {
|
|
105
|
+
...lifecycle,
|
|
106
|
+
pane: {
|
|
107
|
+
kind: "read-error",
|
|
108
|
+
firstFailedAt: previous?.firstFailedAt ?? observedAt,
|
|
109
|
+
lastFailedAt: observedAt,
|
|
110
|
+
consecutiveFailures: (previous?.consecutiveFailures ?? 0) + 1,
|
|
111
|
+
error: inspection.error,
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (inspection.kind === "missing") {
|
|
117
|
+
return {
|
|
118
|
+
...lifecycle,
|
|
119
|
+
pane: { kind: "missing", detectedAt: observedAt, ...(inspection.error ? { error: inspection.error } : {}) },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const agentStatus = inspection.agentStatus;
|
|
124
|
+
const hasWorked =
|
|
125
|
+
lifecycle.hasWorked ||
|
|
126
|
+
agentStatus === "working" ||
|
|
127
|
+
agentStatus === "blocked" ||
|
|
128
|
+
agentStatus === "done";
|
|
129
|
+
|
|
130
|
+
const pane: PaneObservation = {
|
|
131
|
+
kind: "present",
|
|
132
|
+
observedAt,
|
|
133
|
+
agentStatus,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const process: ProcessState = lifecycle.process.kind === "starting"
|
|
137
|
+
? { kind: "running", startedAt: lifecycle.process.startedAt, confirmedAt: observedAt }
|
|
138
|
+
: lifecycle.process;
|
|
139
|
+
|
|
140
|
+
// A local interrupt has higher precedence than coarse Herdr status. Herdr
|
|
141
|
+
// can lag behind Escape; only newer Pi activity or completion clears it.
|
|
142
|
+
if (lifecycle.turn.kind === "interrupted") {
|
|
143
|
+
return { ...lifecycle, process, pane, hasWorked };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let turn: TurnState = lifecycle.turn;
|
|
147
|
+
if (agentStatus === "blocked") {
|
|
148
|
+
turn = hasWorked
|
|
149
|
+
? {
|
|
150
|
+
kind: "blocked",
|
|
151
|
+
startedAt: lifecycle.turn.kind === "blocked" ? lifecycle.turn.startedAt : observedAt,
|
|
152
|
+
}
|
|
153
|
+
: {
|
|
154
|
+
kind: "starting",
|
|
155
|
+
observedAt: lifecycle.turn.kind === "starting" ? lifecycle.turn.observedAt : observedAt,
|
|
156
|
+
};
|
|
157
|
+
} else if (agentStatus === "working") {
|
|
158
|
+
turn = {
|
|
159
|
+
kind: "active",
|
|
160
|
+
startedAt: lifecycle.turn.kind === "active" ? lifecycle.turn.startedAt : observedAt,
|
|
161
|
+
source: "herdr",
|
|
162
|
+
...(lifecycle.activityDetail ? { activity: lifecycle.activityDetail } : {}),
|
|
163
|
+
};
|
|
164
|
+
} else if (agentStatus === "done" || agentStatus === "idle") {
|
|
165
|
+
turn = hasWorked
|
|
166
|
+
? {
|
|
167
|
+
kind: "waiting",
|
|
168
|
+
startedAt: lifecycle.turn.kind === "waiting" ? lifecycle.turn.startedAt : observedAt,
|
|
169
|
+
}
|
|
170
|
+
: {
|
|
171
|
+
kind: "starting",
|
|
172
|
+
observedAt: lifecycle.turn.kind === "starting" ? lifecycle.turn.observedAt : observedAt,
|
|
173
|
+
};
|
|
174
|
+
} else if (agentStatus === "unknown") {
|
|
175
|
+
// Keep existing process/turn; only record observation.
|
|
176
|
+
return { ...lifecycle, process, pane };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
...lifecycle,
|
|
181
|
+
process,
|
|
182
|
+
turn,
|
|
183
|
+
pane,
|
|
184
|
+
hasWorked,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function observeActivity(
|
|
189
|
+
lifecycle: SubagentLifecycle,
|
|
190
|
+
read: ActivityReadResult,
|
|
191
|
+
observedAt: number,
|
|
192
|
+
): SubagentLifecycle {
|
|
193
|
+
if (lifecycle.process.kind === "finalizing" || isTerminal(lifecycle.process)) return lifecycle;
|
|
194
|
+
|
|
195
|
+
const detail: ActivityDetail | null = (() => {
|
|
196
|
+
if (!read.ok) return null;
|
|
197
|
+
const activity = read.activity;
|
|
198
|
+
if (lifecycle.lastActivitySequence != null && activity.sequence < lifecycle.lastActivitySequence) {
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
if (activity.phase !== "active") return null;
|
|
202
|
+
if (activity.activeScope === "tool") {
|
|
203
|
+
return {
|
|
204
|
+
kind: "scope",
|
|
205
|
+
scope: "tool",
|
|
206
|
+
since: activity.toolStartedAt ?? activity.activeSince ?? activity.updatedAt,
|
|
207
|
+
observedAt: activity.updatedAt,
|
|
208
|
+
sequence: activity.sequence,
|
|
209
|
+
...(activity.toolName ? { label: activity.toolName } : {}),
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
if (activity.activeScope === "provider") {
|
|
213
|
+
return { kind: "scope", scope: "provider", since: activity.activeSince ?? activity.updatedAt, observedAt: activity.updatedAt, sequence: activity.sequence, label: "provider" };
|
|
214
|
+
}
|
|
215
|
+
if (activity.activeScope === "streaming") {
|
|
216
|
+
return { kind: "scope", scope: "streaming", since: activity.activeSince ?? activity.updatedAt, observedAt: activity.updatedAt, sequence: activity.sequence, label: "streaming" };
|
|
217
|
+
}
|
|
218
|
+
if (activity.activeScope === "agent" || activity.activeScope === "turn") {
|
|
219
|
+
return { kind: "scope", scope: activity.activeScope, since: activity.activeSince ?? activity.updatedAt, observedAt: activity.updatedAt, sequence: activity.sequence };
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
})();
|
|
223
|
+
|
|
224
|
+
if (!read.ok) {
|
|
225
|
+
const since = lifecycle.activityHealth.kind === "problem"
|
|
226
|
+
? lifecycle.activityHealth.since
|
|
227
|
+
: observedAt;
|
|
228
|
+
return {
|
|
229
|
+
...lifecycle,
|
|
230
|
+
activityHealth: { kind: "problem", reason: read.reason, since, ...(read.error ? { error: read.error } : {}) },
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (!detail) {
|
|
235
|
+
// Reading succeeded but no enrichable detail; clear any stale label.
|
|
236
|
+
return {
|
|
237
|
+
...lifecycle,
|
|
238
|
+
activityDetail: null,
|
|
239
|
+
activityHealth: { kind: "healthy", observedAt },
|
|
240
|
+
lastActivitySequence: Math.max(lifecycle.lastActivitySequence ?? -1, read.activity.sequence),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let resumesInterruptedTurn = false;
|
|
245
|
+
if (lifecycle.turn.kind === "interrupted") {
|
|
246
|
+
const staleInterruptSnapshot = detail.observedAt < lifecycle.turn.requestedAt ||
|
|
247
|
+
(detail.observedAt === lifecycle.turn.requestedAt &&
|
|
248
|
+
lifecycle.turn.previousActivitySequence != null &&
|
|
249
|
+
detail.sequence <= lifecycle.turn.previousActivitySequence);
|
|
250
|
+
if (staleInterruptSnapshot) return lifecycle;
|
|
251
|
+
resumesInterruptedTurn = true;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const process: ProcessState = lifecycle.process.kind === "starting"
|
|
255
|
+
? { kind: "running", startedAt: lifecycle.process.startedAt, confirmedAt: observedAt }
|
|
256
|
+
: lifecycle.process;
|
|
257
|
+
|
|
258
|
+
// Herdr owns coarse turn state. Pi detail may enrich an authoritative
|
|
259
|
+
// Herdr-working turn, or provide a fallback only while pane status is unknown.
|
|
260
|
+
let turn = lifecycle.turn;
|
|
261
|
+
const sameDetail = lifecycle.activityDetail?.kind === "scope" &&
|
|
262
|
+
lifecycle.activityDetail.scope === detail.scope &&
|
|
263
|
+
lifecycle.activityDetail.label === detail.label;
|
|
264
|
+
const detailStartedAt = sameDetail && lifecycle.turn.kind === "active"
|
|
265
|
+
? lifecycle.turn.startedAt
|
|
266
|
+
: detail.since;
|
|
267
|
+
|
|
268
|
+
if (resumesInterruptedTurn) {
|
|
269
|
+
turn = {
|
|
270
|
+
kind: "active",
|
|
271
|
+
startedAt: detailStartedAt,
|
|
272
|
+
source: "activity",
|
|
273
|
+
activity: detail,
|
|
274
|
+
};
|
|
275
|
+
} else if (lifecycle.turn.kind !== "interrupted") {
|
|
276
|
+
if (lifecycle.pane.kind === "present" && lifecycle.pane.agentStatus === "working") {
|
|
277
|
+
turn = {
|
|
278
|
+
kind: "active",
|
|
279
|
+
startedAt: detailStartedAt,
|
|
280
|
+
source: "activity",
|
|
281
|
+
activity: detail,
|
|
282
|
+
};
|
|
283
|
+
} else if (lifecycle.pane.kind === "unknown" || lifecycle.pane.kind === "read-error") {
|
|
284
|
+
turn = {
|
|
285
|
+
kind: "active",
|
|
286
|
+
startedAt: detailStartedAt,
|
|
287
|
+
source: "fallback",
|
|
288
|
+
activity: detail,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return {
|
|
294
|
+
...lifecycle,
|
|
295
|
+
process,
|
|
296
|
+
turn,
|
|
297
|
+
activityDetail: detail,
|
|
298
|
+
activityHealth: { kind: "healthy", observedAt },
|
|
299
|
+
lastActivitySequence: detail.sequence,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function markProcessRunning(
|
|
304
|
+
lifecycle: SubagentLifecycle,
|
|
305
|
+
confirmedAt: number,
|
|
306
|
+
): SubagentLifecycle {
|
|
307
|
+
if (lifecycle.process.kind !== "starting") return lifecycle;
|
|
308
|
+
return {
|
|
309
|
+
...lifecycle,
|
|
310
|
+
process: { kind: "running", startedAt: lifecycle.process.startedAt, confirmedAt },
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function markInterruptRequested(
|
|
315
|
+
lifecycle: SubagentLifecycle,
|
|
316
|
+
requestedAt: number,
|
|
317
|
+
): SubagentLifecycle {
|
|
318
|
+
if (lifecycle.process.kind === "finalizing" || isTerminal(lifecycle.process)) return lifecycle;
|
|
319
|
+
return {
|
|
320
|
+
...lifecycle,
|
|
321
|
+
turn: {
|
|
322
|
+
kind: "interrupted",
|
|
323
|
+
requestedAt,
|
|
324
|
+
previousActivitySequence: lifecycle.lastActivitySequence,
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
export function markCompletionDetected(
|
|
330
|
+
lifecycle: SubagentLifecycle,
|
|
331
|
+
completion: CompletionResult,
|
|
332
|
+
detectedAt: number,
|
|
333
|
+
): SubagentLifecycle {
|
|
334
|
+
if (lifecycle.process.kind === "finalizing" || isTerminal(lifecycle.process)) return lifecycle;
|
|
335
|
+
return {
|
|
336
|
+
...lifecycle,
|
|
337
|
+
process: {
|
|
338
|
+
kind: "finalizing",
|
|
339
|
+
startedAt: startedAt(lifecycle.process),
|
|
340
|
+
detectedAt: Math.max(startedAt(lifecycle.process), detectedAt),
|
|
341
|
+
completion,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function markCompleted(lifecycle: SubagentLifecycle, completedAt: number): SubagentLifecycle {
|
|
347
|
+
if (isTerminal(lifecycle.process)) return lifecycle;
|
|
348
|
+
if (lifecycle.process.kind !== "finalizing") return lifecycle;
|
|
349
|
+
return {
|
|
350
|
+
...lifecycle,
|
|
351
|
+
process: {
|
|
352
|
+
kind: "completed",
|
|
353
|
+
startedAt: lifecycle.process.startedAt,
|
|
354
|
+
detectedAt: lifecycle.process.detectedAt,
|
|
355
|
+
completedAt: Math.max(lifecycle.process.detectedAt, completedAt),
|
|
356
|
+
completion: lifecycle.process.completion,
|
|
357
|
+
},
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
export function markFailed(
|
|
362
|
+
lifecycle: SubagentLifecycle,
|
|
363
|
+
error: string,
|
|
364
|
+
detectedAt: number,
|
|
365
|
+
exitCode?: number,
|
|
366
|
+
): SubagentLifecycle {
|
|
367
|
+
if (isTerminal(lifecycle.process)) return lifecycle;
|
|
368
|
+
const start = startedAt(lifecycle.process);
|
|
369
|
+
const detected = lifecycle.process.kind === "finalizing"
|
|
370
|
+
? lifecycle.process.detectedAt
|
|
371
|
+
: Math.max(start, detectedAt);
|
|
372
|
+
return {
|
|
373
|
+
...lifecycle,
|
|
374
|
+
process: {
|
|
375
|
+
kind: "failed",
|
|
376
|
+
startedAt: start,
|
|
377
|
+
detectedAt: detected,
|
|
378
|
+
completedAt: Math.max(detected, detectedAt),
|
|
379
|
+
error,
|
|
380
|
+
...(exitCode == null ? {} : { exitCode }),
|
|
381
|
+
},
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function markDelivery(lifecycle: SubagentLifecycle, delivery: CompletionDelivery): SubagentLifecycle {
|
|
386
|
+
if (lifecycle.delivery !== "pending") return lifecycle;
|
|
387
|
+
return { ...lifecycle, delivery };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function projectLifecycle(lifecycle: SubagentLifecycle, now: number): LifecycleProjection {
|
|
391
|
+
const process = lifecycle.process;
|
|
392
|
+
if (process.kind === "finalizing") return { kind: "finalizing", runtimeEndedAt: process.detectedAt };
|
|
393
|
+
if (process.kind === "completed") return { kind: "completed", runtimeEndedAt: process.completedAt };
|
|
394
|
+
if (process.kind === "failed") return { kind: "failed", label: process.error, runtimeEndedAt: process.completedAt };
|
|
395
|
+
|
|
396
|
+
// Pi activity is optional enrichment. Only authoritative Herdr inspection
|
|
397
|
+
// unavailability may produce a stalled projection.
|
|
398
|
+
if (
|
|
399
|
+
lifecycle.pane.kind === "read-error" &&
|
|
400
|
+
now - lifecycle.pane.firstFailedAt >= 60_000
|
|
401
|
+
) {
|
|
402
|
+
return { kind: "stalled", stateDurationSince: lifecycle.pane.firstFailedAt };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const turn = lifecycle.turn;
|
|
406
|
+
switch (turn.kind) {
|
|
407
|
+
case "interrupted":
|
|
408
|
+
return { kind: "interrupted", stateDurationSince: turn.requestedAt };
|
|
409
|
+
case "active": {
|
|
410
|
+
if (turn.activity?.kind === "scope") {
|
|
411
|
+
const label = turn.activity.label ?? turn.activity.scope;
|
|
412
|
+
return { kind: "active", label, stateDurationSince: turn.startedAt };
|
|
413
|
+
}
|
|
414
|
+
return { kind: "active", label: turn.source === "herdr" ? "agent working" : "agent active", stateDurationSince: turn.startedAt };
|
|
415
|
+
}
|
|
416
|
+
case "blocked":
|
|
417
|
+
return { kind: "blocked", stateDurationSince: turn.startedAt };
|
|
418
|
+
case "waiting":
|
|
419
|
+
return { kind: "waiting", stateDurationSince: turn.startedAt };
|
|
420
|
+
case "starting":
|
|
421
|
+
return { kind: "starting", stateDurationSince: turn.observedAt };
|
|
422
|
+
case "unknown":
|
|
423
|
+
return process.kind === "running" ? { kind: "running" } : { kind: "starting" };
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export type LifecycleTransition = "stalled" | "recovered" | null;
|
|
428
|
+
|
|
429
|
+
export function lifecycleTransition(
|
|
430
|
+
previous: LifecycleProjection["kind"] | undefined,
|
|
431
|
+
next: LifecycleProjection["kind"],
|
|
432
|
+
): LifecycleTransition {
|
|
433
|
+
if (previous !== "stalled" && next === "stalled") return "stalled";
|
|
434
|
+
if (
|
|
435
|
+
previous === "stalled" &&
|
|
436
|
+
(next === "active" ||
|
|
437
|
+
next === "blocked" ||
|
|
438
|
+
next === "waiting" ||
|
|
439
|
+
next === "interrupted" ||
|
|
440
|
+
next === "running" ||
|
|
441
|
+
next === "starting")
|
|
442
|
+
) {
|
|
443
|
+
return "recovered";
|
|
444
|
+
}
|
|
445
|
+
return null;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function formatLifecycleTransitionLine(
|
|
449
|
+
name: string,
|
|
450
|
+
projection: LifecycleProjection,
|
|
451
|
+
transition: Exclude<LifecycleTransition, null>,
|
|
452
|
+
now: number,
|
|
453
|
+
startedAt: number,
|
|
454
|
+
formatElapsed: (ms: number) => string,
|
|
455
|
+
): string {
|
|
456
|
+
const runtime = formatElapsed(Math.max(0, now - startedAt));
|
|
457
|
+
const duration = projection.stateDurationSince == null
|
|
458
|
+
? ""
|
|
459
|
+
: ` ${formatElapsed(now - projection.stateDurationSince)}`;
|
|
460
|
+
if (transition === "stalled") {
|
|
461
|
+
return `${name} running ${runtime}, stalled${duration}.`;
|
|
462
|
+
}
|
|
463
|
+
if (projection.kind === "waiting") {
|
|
464
|
+
return `${name} running ${runtime}, recovered; waiting${duration}.`;
|
|
465
|
+
}
|
|
466
|
+
if (projection.kind === "active") {
|
|
467
|
+
const detail = projection.label ? ` (${projection.label}${duration})` : duration;
|
|
468
|
+
return `${name} running ${runtime}, recovered; active${detail}.`;
|
|
469
|
+
}
|
|
470
|
+
if (projection.kind === "blocked") {
|
|
471
|
+
return `${name} running ${runtime}, recovered; blocked${duration}.`;
|
|
472
|
+
}
|
|
473
|
+
if (projection.kind === "interrupted") {
|
|
474
|
+
return `${name} running ${runtime}, recovered; interrupted${duration}.`;
|
|
475
|
+
}
|
|
476
|
+
return `${name} running ${runtime}, recovered; running.`;
|
|
477
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
6
|
+
const DEFAULT_MODEL_CONFIG_PATH = join(PACKAGE_ROOT, "config.json");
|
|
7
|
+
|
|
8
|
+
export interface ModelConfig {
|
|
9
|
+
default?: string;
|
|
10
|
+
agents: Record<string, string>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function invalidModelConfig(source: string, message: string): never {
|
|
14
|
+
throw new Error(`Invalid subagent model config in ${source}: ${message}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseModelConfig(rawConfig: unknown, source = "config.json"): ModelConfig {
|
|
18
|
+
if (rawConfig == null || typeof rawConfig !== "object" || Array.isArray(rawConfig)) {
|
|
19
|
+
invalidModelConfig(source, "root must be an object");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const config = rawConfig as Record<string, unknown>;
|
|
23
|
+
const models = config.models;
|
|
24
|
+
if (models == null) return { agents: {} };
|
|
25
|
+
if (typeof models !== "object" || Array.isArray(models)) {
|
|
26
|
+
invalidModelConfig(source, "models must be an object");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const value = models as Record<string, unknown>;
|
|
30
|
+
const allowedKeys = new Set(["default", "agents"]);
|
|
31
|
+
const unsupportedKeys = Object.keys(value).filter((key) => !allowedKeys.has(key));
|
|
32
|
+
if (unsupportedKeys.length > 0) {
|
|
33
|
+
invalidModelConfig(source, `models has unsupported key(s): ${unsupportedKeys.join(", ")}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
let defaultModel: string | undefined;
|
|
37
|
+
if (value.default != null) {
|
|
38
|
+
if (typeof value.default !== "string" || value.default.trim() === "") {
|
|
39
|
+
invalidModelConfig(source, "models.default must be a non-empty string");
|
|
40
|
+
}
|
|
41
|
+
defaultModel = value.default.trim();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const agents: Record<string, string> = {};
|
|
45
|
+
if (value.agents != null) {
|
|
46
|
+
if (typeof value.agents !== "object" || Array.isArray(value.agents)) {
|
|
47
|
+
invalidModelConfig(source, "models.agents must be an object");
|
|
48
|
+
}
|
|
49
|
+
for (const [agent, model] of Object.entries(value.agents as Record<string, unknown>)) {
|
|
50
|
+
if (typeof model !== "string" || model.trim() === "") {
|
|
51
|
+
invalidModelConfig(source, `models.agents.${agent} must be a non-empty string`);
|
|
52
|
+
}
|
|
53
|
+
Object.defineProperty(agents, agent, {
|
|
54
|
+
value: model.trim(),
|
|
55
|
+
enumerable: true,
|
|
56
|
+
writable: true,
|
|
57
|
+
configurable: true,
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return { default: defaultModel, agents };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function resolveModelDefault(
|
|
66
|
+
agentName: string | undefined,
|
|
67
|
+
agentModel: string | undefined,
|
|
68
|
+
config: ModelConfig,
|
|
69
|
+
): string | undefined {
|
|
70
|
+
if (agentModel) return agentModel;
|
|
71
|
+
if (agentName && Object.hasOwn(config.agents, agentName)) {
|
|
72
|
+
return config.agents[agentName];
|
|
73
|
+
}
|
|
74
|
+
return config.default;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function loadModelConfig(configPath = DEFAULT_MODEL_CONFIG_PATH): ModelConfig {
|
|
78
|
+
let raw: string;
|
|
79
|
+
try {
|
|
80
|
+
raw = readFileSync(configPath, "utf8");
|
|
81
|
+
} catch (error) {
|
|
82
|
+
const errno = error as NodeJS.ErrnoException;
|
|
83
|
+
if (errno.code === "ENOENT") return { agents: {} };
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let parsed: unknown;
|
|
88
|
+
try {
|
|
89
|
+
parsed = JSON.parse(raw) as unknown;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
92
|
+
throw new Error(`Invalid JSON in subagent model config ${configPath}: ${detail}`);
|
|
93
|
+
}
|
|
94
|
+
return parseModelConfig(parsed, configPath);
|
|
95
|
+
}
|