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.
Files changed (49) hide show
  1. package/AGENTS.md +116 -0
  2. package/CONTEXT.md +159 -0
  3. package/LICENSE +21 -0
  4. package/README.md +874 -0
  5. package/RELEASING.md +139 -0
  6. package/agents/adversarial-reviewer.md +80 -0
  7. package/agents/claude-reviewer.md +23 -0
  8. package/agents/planner.md +539 -0
  9. package/agents/poteto.md +32 -0
  10. package/agents/reviewer.md +164 -0
  11. package/agents/scout.md +106 -0
  12. package/agents/visual-tester.md +224 -0
  13. package/agents/worker.md +132 -0
  14. package/config.json.example +8 -0
  15. package/docs/README.md +42 -0
  16. package/docs/adr/0001-btw-ephemeral-side-questions.md +142 -0
  17. package/docs/adr/0002-agent-workflow-skill-runtime-taxonomy.md +265 -0
  18. package/docs/adr/0003-installable-role-packs.md +135 -0
  19. package/docs/adr/0004-require-active-user-approval-for-workflow-execution.md +17 -0
  20. package/docs/adr/0005-parent-owns-workflow-script-authority.md +17 -0
  21. package/docs/adr/0006-limit-v1-execution-effects-to-isolated-worktrees.md +18 -0
  22. package/docs/adr/0007-require-fresh-review-for-workflow-scripts.md +19 -0
  23. package/docs/orchestrated-review-workflow-plan.md +479 -0
  24. package/docs/research/pdw-architecture-assessment.md +525 -0
  25. package/docs/research/pi-workflows-sol-advisor.md +255 -0
  26. package/docs/research/worktree-subagent-orchestration.md +317 -0
  27. package/docs/worktree-subagents.md +196 -0
  28. package/examples/role-pack/extension.ts +18 -0
  29. package/examples/role-pack/package.json +16 -0
  30. package/examples/role-pack/roles/example-reviewer.md +12 -0
  31. package/package.json +58 -0
  32. package/pi-extension/subagents/activity.ts +511 -0
  33. package/pi-extension/subagents/completion.ts +177 -0
  34. package/pi-extension/subagents/herdr.ts +541 -0
  35. package/pi-extension/subagents/index.ts +4730 -0
  36. package/pi-extension/subagents/lifecycle.ts +477 -0
  37. package/pi-extension/subagents/model-config.ts +95 -0
  38. package/pi-extension/subagents/plan-skill.md +262 -0
  39. package/pi-extension/subagents/plugin/.claude-plugin/plugin.json +5 -0
  40. package/pi-extension/subagents/plugin/hooks/hooks.json +15 -0
  41. package/pi-extension/subagents/plugin/hooks/on-stop.sh +68 -0
  42. package/pi-extension/subagents/runtime-routing.ts +313 -0
  43. package/pi-extension/subagents/session.ts +216 -0
  44. package/pi-extension/subagents/status.ts +513 -0
  45. package/pi-extension/subagents/subagent-done.ts +326 -0
  46. package/pi-extension/subagents/terminal.ts +163 -0
  47. package/pi-extension/subagents/workflow-worker.js +56 -0
  48. package/pi-extension/subagents/workflow.ts +1210 -0
  49. 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
+ }