@aeondave/pi-persona 1.15.2

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 (165) hide show
  1. package/AGENTS.md +249 -0
  2. package/CHANGELOG.md +260 -0
  3. package/LICENSE +21 -0
  4. package/README.md +172 -0
  5. package/agents/balthasar.md +32 -0
  6. package/agents/casper.md +33 -0
  7. package/agents/evidence-verifier.md +23 -0
  8. package/agents/melchior.md +32 -0
  9. package/agents/operator.md +63 -0
  10. package/agents/research.md +58 -0
  11. package/agents/reviewer.md +49 -0
  12. package/agents/scout.md +23 -0
  13. package/agents/verifier.md +21 -0
  14. package/assets/banner.png +0 -0
  15. package/assets/demo1.gif +0 -0
  16. package/assets/demo1.mp4 +0 -0
  17. package/assets/workflow.png +0 -0
  18. package/contracts/finding.contract.json +9 -0
  19. package/docs/ARCHITECTURE.md +727 -0
  20. package/docs/EXPERIENCE.md +96 -0
  21. package/docs/MONITORS.md +84 -0
  22. package/docs/README.md +53 -0
  23. package/docs/REFERENCE.md +408 -0
  24. package/docs/SPINE.md +152 -0
  25. package/docs/STRATEGIES.md +215 -0
  26. package/docs/TELEMETRY.md +9 -0
  27. package/flows/deep-review.flow.json +8 -0
  28. package/flows/gated-build.flow.json +9 -0
  29. package/package.json +106 -0
  30. package/personas/audit.md +29 -0
  31. package/personas/dev.md +52 -0
  32. package/personas/elite.md +256 -0
  33. package/personas/judge.md +32 -0
  34. package/personas/magi.md +41 -0
  35. package/personas/planner.md +37 -0
  36. package/personas/researcher.md +37 -0
  37. package/personas/swarm.md +38 -0
  38. package/personas/verify.md +31 -0
  39. package/presets/magi-rounds.preset.json +5 -0
  40. package/prompts/spine.md +11 -0
  41. package/prompts/spine.worker.md +9 -0
  42. package/src/agents/agent.ts +78 -0
  43. package/src/bridge.ts +209 -0
  44. package/src/bus/broker/client.ts +379 -0
  45. package/src/bus/broker/framing.ts +43 -0
  46. package/src/bus/broker/host.ts +407 -0
  47. package/src/bus/broker/messages.ts +87 -0
  48. package/src/bus/broker/paths.ts +31 -0
  49. package/src/bus/contact.ts +145 -0
  50. package/src/bus/inproc.ts +279 -0
  51. package/src/bus/peers.ts +114 -0
  52. package/src/core/brief.ts +356 -0
  53. package/src/core/capabilities.ts +114 -0
  54. package/src/core/config.ts +228 -0
  55. package/src/core/contract.ts +391 -0
  56. package/src/core/data-root.ts +260 -0
  57. package/src/core/discovery.ts +33 -0
  58. package/src/core/display-label.ts +47 -0
  59. package/src/core/fence.ts +151 -0
  60. package/src/core/format.ts +13 -0
  61. package/src/core/frontmatter.ts +202 -0
  62. package/src/core/models.ts +192 -0
  63. package/src/core/monitor.ts +415 -0
  64. package/src/core/naming.ts +42 -0
  65. package/src/core/nudge.ts +238 -0
  66. package/src/core/ownership.ts +102 -0
  67. package/src/core/permissions.ts +56 -0
  68. package/src/core/pi-compat.ts +50 -0
  69. package/src/core/seed.ts +770 -0
  70. package/src/core/session-identity.ts +89 -0
  71. package/src/core/time.ts +202 -0
  72. package/src/core/timer.ts +221 -0
  73. package/src/core/types.ts +28 -0
  74. package/src/engine/adapter.ts +257 -0
  75. package/src/engine/async.ts +898 -0
  76. package/src/engine/change-report.ts +143 -0
  77. package/src/engine/child.ts +532 -0
  78. package/src/engine/errors.ts +30 -0
  79. package/src/engine/fallback.ts +57 -0
  80. package/src/engine/handles.ts +21 -0
  81. package/src/engine/inproc.ts +857 -0
  82. package/src/engine/signals.ts +11 -0
  83. package/src/engine/spec-preflight.ts +33 -0
  84. package/src/engine/stream.ts +369 -0
  85. package/src/engine/worktree.ts +144 -0
  86. package/src/exocom/activation.ts +31 -0
  87. package/src/exocom/codes.ts +261 -0
  88. package/src/exocom/envelope.ts +180 -0
  89. package/src/exocom/gate.ts +6 -0
  90. package/src/exocom/guards.ts +45 -0
  91. package/src/exocom/inbound.ts +141 -0
  92. package/src/exocom/install.ts +970 -0
  93. package/src/exocom/ledger.ts +603 -0
  94. package/src/exocom/limits.ts +20 -0
  95. package/src/exocom/paths.ts +35 -0
  96. package/src/exocom/plane.ts +1116 -0
  97. package/src/exocom/registry.ts +307 -0
  98. package/src/exocom/scope.ts +42 -0
  99. package/src/exocom/status.ts +86 -0
  100. package/src/exocom/untrusted.ts +40 -0
  101. package/src/exocom/wait.ts +23 -0
  102. package/src/exocom/write-guard.ts +89 -0
  103. package/src/extension/broker-host.ts +181 -0
  104. package/src/extension/clock.ts +14 -0
  105. package/src/extension/engine.ts +321 -0
  106. package/src/extension/hooks.ts +796 -0
  107. package/src/extension/identity.ts +244 -0
  108. package/src/extension/shared.ts +175 -0
  109. package/src/extension.ts +2221 -0
  110. package/src/loader.ts +218 -0
  111. package/src/monitor/process.ts +116 -0
  112. package/src/monitor/session.ts +61 -0
  113. package/src/orchestration/flow-journal.ts +86 -0
  114. package/src/orchestration/flow-run.ts +301 -0
  115. package/src/orchestration/flow.ts +125 -0
  116. package/src/orchestration/judge.ts +78 -0
  117. package/src/orchestration/model-retry.ts +85 -0
  118. package/src/orchestration/parallel.ts +107 -0
  119. package/src/orchestration/params.ts +23 -0
  120. package/src/orchestration/reducers.ts +123 -0
  121. package/src/orchestration/render.ts +127 -0
  122. package/src/orchestration/roster.ts +133 -0
  123. package/src/orchestration/sdk.ts +326 -0
  124. package/src/orchestration/strategies/compete.ts +182 -0
  125. package/src/orchestration/strategies/council-rounds.ts +124 -0
  126. package/src/orchestration/strategies/critic-loop.ts +168 -0
  127. package/src/orchestration/strategies/debate.ts +126 -0
  128. package/src/orchestration/strategies/fanout.ts +20 -0
  129. package/src/orchestration/strategies/judge.ts +117 -0
  130. package/src/orchestration/strategies/magi.ts +239 -0
  131. package/src/orchestration/strategies/map.ts +327 -0
  132. package/src/orchestration/strategies/pair.ts +72 -0
  133. package/src/orchestration/strategies/pipeline.ts +91 -0
  134. package/src/orchestration/strategies/synthesize.ts +99 -0
  135. package/src/orchestration/strategy.ts +49 -0
  136. package/src/orchestration/types.ts +31 -0
  137. package/src/orchestration/voting.ts +126 -0
  138. package/src/persona/config-store.ts +215 -0
  139. package/src/persona/controller.ts +173 -0
  140. package/src/persona/gating.ts +82 -0
  141. package/src/persona/orchestrate.ts +83 -0
  142. package/src/persona/persona.ts +294 -0
  143. package/src/persona/spine.ts +221 -0
  144. package/src/persona/state.ts +28 -0
  145. package/src/telemetry/contract.ts +244 -0
  146. package/src/telemetry/paths.ts +14 -0
  147. package/src/telemetry/producer.ts +390 -0
  148. package/src/tools/council.ts +200 -0
  149. package/src/tools/delegate-tool.ts +637 -0
  150. package/src/tools/delegate.ts +661 -0
  151. package/src/tools/exocom-work.ts +328 -0
  152. package/src/tools/exocom.ts +476 -0
  153. package/src/tools/flow.ts +117 -0
  154. package/src/tools/intercom-tool.ts +292 -0
  155. package/src/tools/intercom.ts +181 -0
  156. package/src/tools/models.ts +39 -0
  157. package/src/tools/monitor.ts +66 -0
  158. package/src/tools/timer.ts +105 -0
  159. package/src/ui/agent-overlay.ts +526 -0
  160. package/src/ui/agent-tree.ts +307 -0
  161. package/src/ui/live-clock.ts +42 -0
  162. package/src/ui/model-picker.ts +160 -0
  163. package/src/ui/presentation.ts +170 -0
  164. package/src/ui/usage.ts +133 -0
  165. package/teams.yaml +27 -0
@@ -0,0 +1,661 @@
1
+ /**
2
+ * The `delegate` tool's core logic — pure, engine-injected, testable. Maps params
3
+ * to engine runs (single + bounded parallel), formats the result, and emits live
4
+ * per-task "views" (running → done/failed) so the UI can show each sub-agent as
5
+ * it runs. A task may carry `skills`/`model`/`tools` to spawn a *dynamic* sub-agent
6
+ * (e.g. the generic `operator` specialised by the skills it loads). Capability
7
+ * gating happens upstream in the `tool_call` hook.
8
+ */
9
+
10
+ import { type ChildUsage, emptyUsage, type ToolEvent } from "../engine/stream.ts";
11
+ import { mapWithConcurrency } from "../orchestration/parallel.ts";
12
+ import { cappedList } from "../core/format.ts";
13
+ import { sanitizeDisplayLabel } from "../core/display-label.ts";
14
+ import { validateParallelWriteSets } from "../core/ownership.ts";
15
+ import { aggregateResults } from "../orchestration/reducers.ts";
16
+ import {
17
+ type AgentRunSpec,
18
+ type InfrastructureFailure,
19
+ isInfrastructureFailure,
20
+ isPositiveFiniteMs,
21
+ legFailure,
22
+ type SteerFn,
23
+ type StrategyEngine,
24
+ } from "../orchestration/sdk.ts";
25
+ import type { AgentResult } from "../orchestration/types.ts";
26
+
27
+ export interface DelegateTask {
28
+ agent: string;
29
+ task: string;
30
+ /** Structured hand-off context. The integration layer may validate this before spawning. */
31
+ brief?: Partial<DelegationBrief>;
32
+ /** A friendly display name (the loader's choice), to tell sub-agents apart. */
33
+ name?: string;
34
+ /** Skills the sub-agent must load first (dynamic specialisation). */
35
+ skills?: string[];
36
+ /** Model override for this sub-agent. */
37
+ model?: string;
38
+ /** Tool allowlist override for this sub-agent. */
39
+ tools?: string[];
40
+ /** Extra system-prompt text: an ON-THE-FLY specialist persona, appended to the
41
+ * agent's own prompt (prompt-level only — capabilities are still the gate). */
42
+ role?: string;
43
+ /** Run this task in an isolated git worktree (edits never touch the main tree). */
44
+ isolation?: "none" | "worktree";
45
+ /** Route this task through the child engine so the sub-agent's MCP (`mcp*`/direct)
46
+ * tools initialize and work (the in-process engine leaves them "not initialized"). */
47
+ mcp?: boolean;
48
+ /** Per-leg override (ms) of the shared idle-timeout ceiling — lets ONE legitimately quiet
49
+ * leg wait longer between progress events without changing its siblings' idle window. Ignored
50
+ * unless a finite, positive number (junk/≤0 falls back to the engine's default). */
51
+ timeoutMs?: number;
52
+ /** Files/directories this leg may modify. Parallel overlap is rejected before spawning. */
53
+ writeSet?: string[];
54
+ /** Output contract enforced by the engine (for example `finding`). */
55
+ outputContract?: string;
56
+ }
57
+
58
+ export interface DelegateParams {
59
+ agent?: string;
60
+ task?: string;
61
+ brief?: Partial<DelegationBrief>;
62
+ name?: string;
63
+ skills?: string[];
64
+ model?: string;
65
+ tools?: string[];
66
+ role?: string;
67
+ isolation?: "none" | "worktree";
68
+ mcp?: boolean;
69
+ /** Per-leg override (ms) of the shared idle-timeout ceiling (single mode). Ignored unless a
70
+ * finite, positive number (junk/≤0 falls back to the engine's default). */
71
+ timeoutMs?: number;
72
+ writeSet?: string[];
73
+ outputContract?: string;
74
+ tasks?: DelegateTask[];
75
+ concurrency?: number;
76
+ async?: boolean;
77
+ sync?: boolean;
78
+ }
79
+
80
+ /** Structured cold-start context that any persona policy may require. */
81
+ export interface DelegationBrief {
82
+ objective: string;
83
+ scopeRoe: string;
84
+ position: string;
85
+ constraints: string | readonly string[];
86
+ requiredArtifacts: string | readonly string[];
87
+ stopConditions: string | readonly string[];
88
+ }
89
+
90
+ const BRIEF_FIELDS = ["objective", "scopeRoe", "position", "constraints", "requiredArtifacts", "stopConditions"] as const;
91
+
92
+ function briefText(value: unknown): string {
93
+ return typeof value === "string" ? value.trim() : "";
94
+ }
95
+
96
+ function briefItems(value: unknown): string[] {
97
+ if (typeof value === "string") return value.trim() ? [value.trim()] : [];
98
+ if (!Array.isArray(value)) return [];
99
+ return value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean);
100
+ }
101
+
102
+ /** Render the brief in a fixed order so a cold-start worker receives identical context every time. */
103
+ export function renderDelegationBrief(brief: Partial<DelegationBrief> | undefined): string {
104
+ if (!brief) return "";
105
+ const lines = [
106
+ "[DELEGATION BRIEF]",
107
+ `Objective: ${briefText(brief.objective) || "(missing)"}`,
108
+ `Scope / Rules of Engagement: ${briefText(brief.scopeRoe) || "(missing)"}`,
109
+ `Position: ${briefText(brief.position) || "(missing)"}`,
110
+ "Constraints:",
111
+ ...briefItems(brief.constraints).map((item) => `- ${item}`),
112
+ "Required artifacts:",
113
+ ...briefItems(brief.requiredArtifacts).map((item) => `- ${item}`),
114
+ "Stop conditions:",
115
+ ...briefItems(brief.stopConditions).map((item) => `- ${item}`),
116
+ "[/DELEGATION BRIEF]",
117
+ ];
118
+ return lines.join("\n");
119
+ }
120
+
121
+ /**
122
+ * Pure, actionable validation for a policy-enabled integration layer. It intentionally does not
123
+ * reject ordinary backwards-compatible delegate calls; callers opt into this check when they
124
+ * require an evidence-grade hand-off.
125
+ */
126
+ export function validateDelegationBrief(params: DelegateParams): string | undefined {
127
+ const targets: Array<{ label: string; brief: Partial<DelegationBrief> | undefined }> = [];
128
+ if (params.tasks && params.tasks.length > 0) {
129
+ params.tasks.forEach((task, index) => targets.push({ label: `tasks[${index}] (${task.agent || "(missing agent)"})`, brief: task.brief }));
130
+ } else if (params.agent !== undefined || params.task !== undefined) {
131
+ targets.push({ label: `agent "${params.agent ?? "(missing)"}"`, brief: params.brief });
132
+ }
133
+ for (const target of targets) {
134
+ const missing = BRIEF_FIELDS.filter((field) => {
135
+ const value = target.brief?.[field];
136
+ return field === "constraints" || field === "requiredArtifacts" || field === "stopConditions"
137
+ ? briefItems(value).length === 0
138
+ : briefText(value).length === 0;
139
+ });
140
+ if (missing.length > 0) {
141
+ return (
142
+ `delegate: ${target.label} requires a complete brief; missing or empty: ${missing.join(", ")}. ` +
143
+ `Provide all fields (${BRIEF_FIELDS.join(", ")}) before spawning the policy-controlled leg. ` +
144
+ (params.tasks && params.tasks.length > 0
145
+ ? "Put a separate complete brief in every tasks[].brief, including read-only workers; a top-level brief is single-mode only. Correct the batch before retrying."
146
+ : "Put the structured brief in the brief field alongside agent and task; task prose does not replace it.")
147
+ );
148
+ }
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ // Moved to core/ownership.ts (so a strategy can call it without importing a tool module);
154
+ // re-exported here unchanged for this file's own call sites and existing tests.
155
+ export { findWriteSetOverlaps, validateParallelWriteSets, type WriteSetOverlap } from "../core/ownership.ts";
156
+
157
+ function unwrapJsonish(value: unknown, label: string): { ok: true; value: unknown } | { ok: false; error: string } {
158
+ let current = value;
159
+ for (let depth = 0; depth < 2; depth++) {
160
+ if (typeof current !== "string") return { ok: true, value: current };
161
+ const trimmed = current.trim();
162
+ if (!(trimmed.startsWith("{") || trimmed.startsWith("["))) return { ok: true, value: current };
163
+ try {
164
+ current = JSON.parse(trimmed);
165
+ } catch (error) {
166
+ const detail = error instanceof Error ? error.message : String(error);
167
+ return { ok: false, error: `delegate: ${label} looks like JSON but did not parse (${detail}). Pass a real array/object, not a string.` };
168
+ }
169
+ }
170
+ return { ok: true, value: current };
171
+ }
172
+
173
+ function coerceStringList(value: unknown, label: string): { ok: true; value?: string[] } | { ok: false; error: string } {
174
+ if (value === undefined) return { ok: true };
175
+ const parsed = unwrapJsonish(value, label);
176
+ if (!parsed.ok) return parsed;
177
+ if (parsed.value === undefined) return { ok: true };
178
+ if (typeof parsed.value === "string") {
179
+ const trimmed = parsed.value.trim();
180
+ return { ok: true, value: trimmed ? [trimmed] : [] };
181
+ }
182
+ if (!Array.isArray(parsed.value)) return { ok: false, error: `delegate: ${label} must be an array of strings.` };
183
+ const out: string[] = [];
184
+ for (let i = 0; i < parsed.value.length; i++) {
185
+ const item = parsed.value[i];
186
+ if (typeof item !== "string") return { ok: false, error: `delegate: ${label}[${i}] must be a string.` };
187
+ out.push(item);
188
+ }
189
+ return { ok: true, value: out };
190
+ }
191
+
192
+ function coerceBriefField(value: unknown, label: string): { ok: true; value?: Partial<DelegationBrief> } | { ok: false; error: string } {
193
+ if (value === undefined) return { ok: true };
194
+ const parsed = unwrapJsonish(value, label);
195
+ if (!parsed.ok) return parsed;
196
+ if (parsed.value === undefined) return { ok: true };
197
+ if (!parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
198
+ return { ok: false, error: `delegate: ${label} must be an object.` };
199
+ }
200
+ const brief = { ...(parsed.value as Record<string, unknown>) };
201
+ for (const field of ["objective", "scopeRoe", "position"] as const) {
202
+ if (typeof brief[field] !== "string") {
203
+ return { ok: false, error: `delegate: ${label}.${field} must be a string.` };
204
+ }
205
+ }
206
+ for (const field of ["constraints", "requiredArtifacts", "stopConditions"] as const) {
207
+ const list = coerceStringList(brief[field], `${label}.${field}`);
208
+ if (!list.ok) return list;
209
+ if (list.value === undefined) return { ok: false, error: `delegate: ${label}.${field} is required.` };
210
+ brief[field] = list.value;
211
+ }
212
+ return { ok: true, value: brief as Partial<DelegationBrief> };
213
+ }
214
+
215
+ function decodedTaskFieldError(bag: Record<string, unknown>, index: number): string | undefined {
216
+ const label = `tasks[${index}]`;
217
+ for (const field of ["agent", "task"] as const) {
218
+ if (typeof bag[field] !== "string") return `delegate: ${label}.${field} must be a string.`;
219
+ }
220
+ for (const field of ["name", "role", "model", "outputContract"] as const) {
221
+ if (bag[field] !== undefined && typeof bag[field] !== "string") {
222
+ return `delegate: ${label}.${field} must be a string.`;
223
+ }
224
+ }
225
+ if (bag.isolation !== undefined && bag.isolation !== "none" && bag.isolation !== "worktree") {
226
+ return `delegate: ${label}.isolation must be "none" or "worktree".`;
227
+ }
228
+ if (bag.mcp !== undefined && typeof bag.mcp !== "boolean") return `delegate: ${label}.mcp must be a boolean.`;
229
+ if (bag.timeoutMs !== undefined && (typeof bag.timeoutMs !== "number" || !Number.isFinite(bag.timeoutMs))) {
230
+ return `delegate: ${label}.timeoutMs must be a finite number.`;
231
+ }
232
+ return undefined;
233
+ }
234
+
235
+ function coerceOneTask(raw: unknown, index: number): { ok: true; task: DelegateTask } | { ok: false; error: string } {
236
+ const parsed = unwrapJsonish(raw, `tasks[${index}]`);
237
+ if (!parsed.ok) return parsed;
238
+ if (!parsed.value || typeof parsed.value !== "object" || Array.isArray(parsed.value)) {
239
+ return { ok: false, error: `delegate: tasks[${index}] must be an object.` };
240
+ }
241
+ const bag = { ...(parsed.value as Record<string, unknown>) };
242
+ const fieldError = decodedTaskFieldError(bag, index);
243
+ if (fieldError) return { ok: false, error: fieldError };
244
+ const brief = coerceBriefField(bag.brief, `tasks[${index}].brief`);
245
+ if (!brief.ok) return brief;
246
+ if (brief.value !== undefined) bag.brief = brief.value;
247
+ const writeSet = coerceStringList(bag.writeSet, `tasks[${index}].writeSet`);
248
+ if (!writeSet.ok) return writeSet;
249
+ if (writeSet.value !== undefined) bag.writeSet = writeSet.value;
250
+ const skills = coerceStringList(bag.skills, `tasks[${index}].skills`);
251
+ if (!skills.ok) return skills;
252
+ if (skills.value !== undefined) bag.skills = skills.value;
253
+ const tools = coerceStringList(bag.tools, `tasks[${index}].tools`);
254
+ if (!tools.ok) return tools;
255
+ if (tools.value !== undefined) bag.tools = tools.value;
256
+ return { ok: true, task: bag as unknown as DelegateTask };
257
+ }
258
+
259
+ function coerceTaskList(value: unknown): { ok: true; value?: DelegateTask[] } | { ok: false; error: string } {
260
+ if (value === undefined) return { ok: true };
261
+ const parsed = unwrapJsonish(value, "tasks");
262
+ if (!parsed.ok) return parsed;
263
+ let list: unknown = parsed.value;
264
+ if (list !== null && typeof list === "object" && !Array.isArray(list)) list = [list];
265
+ if (!Array.isArray(list)) return { ok: false, error: "delegate: tasks must be an array of objects, not a string." };
266
+ const tasks: DelegateTask[] = [];
267
+ for (let i = 0; i < list.length; i++) {
268
+ const task = coerceOneTask(list[i], i);
269
+ if (!task.ok) return task;
270
+ tasks.push(task.task);
271
+ }
272
+ return { ok: true, value: tasks };
273
+ }
274
+
275
+ /**
276
+ * Models often stringify nested `tasks`/`brief`/`writeSet`. Unwrap JSON text so a 9k-character
277
+ * string is not validated as `tasks.0` (first character) or rendered as `parallel (9445)`.
278
+ */
279
+ export function coerceDelegateParams(raw: unknown): { ok: true; params: DelegateParams } | { ok: false; error: string } {
280
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
281
+ return { ok: false, error: "delegate: arguments must be an object." };
282
+ }
283
+ const bag = { ...(raw as Record<string, unknown>) };
284
+ const tasks = coerceTaskList(bag.tasks);
285
+ if (!tasks.ok) return tasks;
286
+ if (tasks.value !== undefined) bag.tasks = tasks.value;
287
+ const brief = coerceBriefField(bag.brief, "brief");
288
+ if (!brief.ok) return brief;
289
+ if (brief.value !== undefined) bag.brief = brief.value;
290
+ const writeSet = coerceStringList(bag.writeSet, "writeSet");
291
+ if (!writeSet.ok) return writeSet;
292
+ if (writeSet.value !== undefined) bag.writeSet = writeSet.value;
293
+ const skills = coerceStringList(bag.skills, "skills");
294
+ if (!skills.ok) return skills;
295
+ if (skills.value !== undefined) bag.skills = skills.value;
296
+ const tools = coerceStringList(bag.tools, "tools");
297
+ if (!tools.ok) return tools;
298
+ if (tools.value !== undefined) bag.tools = tools.value;
299
+ return { ok: true, params: bag as DelegateParams };
300
+ }
301
+
302
+ /** A live per-sub-agent view for the UI (running → done/failed). */
303
+ export interface DelegateView {
304
+ agent: string;
305
+ /** Display label: `name · model` (e.g. "kiln-port · sonnet-4-6"). */
306
+ label: string;
307
+ running: boolean;
308
+ ok: boolean;
309
+ /** Terminal failure classification; `abort` projects as stopped rather than failed in the UI. */
310
+ failureKind?: AgentResult["failureKind"];
311
+ output: string;
312
+ /** The tool the leg is currently running (e.g. "grep src/…"), "" if none. */
313
+ activity: string;
314
+ usage: ChildUsage;
315
+ }
316
+
317
+ /** Short model name for display: drop provider + `claude-` + any `:thinking`. */
318
+ export function shortModel(ref: string | undefined): string {
319
+ if (!ref) return "";
320
+ const base = ref.split(":")[0]?.split("/").pop() ?? ref;
321
+ return base.replace(/^claude-/, "");
322
+ }
323
+
324
+ /** The bare display NAME for a sub-agent (no model suffix): the loader's name, or a codename
325
+ * for a generic operator, else the agent's own name. Exported so a caller that folds in the
326
+ * model itself (e.g. the async tree node, `AsyncRun.label`) doesn't have to strip it back out
327
+ * of {@link labelFor}'s composite string. */
328
+ export function nameFor(t: { agent: string; name?: string }, index: number): string {
329
+ // No catalog of call-signs: an unnamed generic operator stays the agent name, disambiguated
330
+ // by index. The model is supposed to invent `<call-sign>-<purpose>` itself; this is only the
331
+ // honest fallback when it doesn't.
332
+ const fallback = t.agent === "operator" && index > 0 ? `operator-${index + 1}` : t.agent;
333
+ return sanitizeDisplayLabel(t.name?.trim() || fallback, fallback);
334
+ }
335
+
336
+ /** Display label for a sub-agent: its bare {@link nameFor} name plus its short model. */
337
+ export function labelFor(t: { agent: string; name?: string; model?: string }, index: number): string {
338
+ const name = nameFor(t, index);
339
+ const m = shortModel(t.model);
340
+ return m ? `${name} · ${m}` : name;
341
+ }
342
+
343
+ export interface DelegateOutcome {
344
+ text: string;
345
+ results: AgentResult[];
346
+ views: DelegateView[];
347
+ ok: boolean;
348
+ }
349
+
350
+ /** Hard ceilings the engine enforces regardless of what the LLM asks for. */
351
+ export interface DelegateLimits {
352
+ maxConcurrency: number;
353
+ maxChildren: number;
354
+ }
355
+
356
+ /** Normalize an LLM-authored concurrency request once for sync scheduling, async scheduling, and
357
+ * policy gates. Fractional/non-finite inputs fail toward serialization instead of rounding up. */
358
+ export function normalizeDelegateConcurrency(value: number | undefined, maximum: number): number {
359
+ const ceiling = Math.max(1, Number.isFinite(maximum) ? Math.floor(maximum) : 1);
360
+ if (value === undefined) return ceiling;
361
+ if (!Number.isFinite(value)) return 1;
362
+ return Math.min(ceiling, Math.max(1, Math.floor(value)));
363
+ }
364
+
365
+ /** Map a task to the run-spec fields the engine accepts — the single source of truth for the
366
+ * delegate path's field mapping. Exported so the async launch path (extension.ts) routes
367
+ * through the SAME mapping instead of duplicating it (a duplicated copy is how NP2's
368
+ * `timeoutMs` could silently miss the async fan-out, which is the interactive default). */
369
+ export function specOf(t: DelegateTask, index = 0): AgentRunSpec {
370
+ const renderedBrief = renderDelegationBrief(t.brief);
371
+ const spec: AgentRunSpec = {
372
+ agent: t.agent,
373
+ name: nameFor(t, index),
374
+ task: renderedBrief ? `${t.task}\n\n${renderedBrief}` : t.task,
375
+ };
376
+ if (t.skills && t.skills.length > 0) spec.skills = t.skills;
377
+ if (t.model) spec.model = t.model;
378
+ if (t.tools !== undefined) spec.tools = t.tools;
379
+ if (t.role?.trim()) spec.role = t.role.trim();
380
+ if (t.outputContract?.trim()) spec.outputContract = t.outputContract.trim();
381
+ if (t.isolation !== undefined) spec.isolation = t.isolation;
382
+ if (t.mcp !== undefined) spec.mcp = t.mcp;
383
+ if (isPositiveFiniteMs(t.timeoutMs)) spec.timeoutMs = t.timeoutMs;
384
+ if (t.writeSet && t.writeSet.length > 0) spec.writeSet = [...t.writeSet];
385
+ return spec;
386
+ }
387
+
388
+ /**
389
+ * Anti-loop ledger — the RUNTIME guard behind "do not re-issue the same failing
390
+ * delegation" (never prompt-only, per the capability guardrails). Tracks consecutive
391
+ * failures per (agent, model, task): after {@link MAX_IDENTICAL_FAILURES} identical
392
+ * failures the delegation is vetoed before anything spawns, so a supervisor that
393
+ * ignores the guidance physically cannot burn tokens on the same dead end. Changing
394
+ * the model or the task is a NEW key — "retry once with a different model" stays open.
395
+ */
396
+ export const MAX_IDENTICAL_FAILURES = 2;
397
+
398
+ /** The fields a delegation is keyed on — always `agent+model+task`; `role`/`tools`/`isolation`
399
+ * additionally fold into the key when {@link DelegationLedger}'s `ledgerV2` is on. */
400
+ export interface DelegationKeyInput {
401
+ agent: string;
402
+ model?: string;
403
+ task: string;
404
+ role?: string;
405
+ tools?: string[];
406
+ isolation?: string;
407
+ }
408
+
409
+ export class DelegationLedger {
410
+ private readonly failures = new Map<string, number>();
411
+ private readonly maxKeys = 200;
412
+ private readonly ledgerV2: boolean;
413
+
414
+ /** `ledgerV2` (PI_PERSONA_LEDGER_V2, default false) widens the veto key — see {@link key}. */
415
+ constructor(opts?: { ledgerV2?: boolean }) {
416
+ this.ledgerV2 = opts?.ledgerV2 ?? false;
417
+ }
418
+
419
+ /** OFF (default): EXACTLY today's `agent+model+task` key — the observable veto *behavior*
420
+ * is preserved (record and vet always route through this SAME function, so whatever
421
+ * separator is in play cancels out on both sides), though the key *bytes* did change
422
+ * (NUL → space → `\x1f` across this batch). ON: additionally folds in `role` + a
423
+ * stable-sorted `tools` join + `isolation`, so a genuine retry that changes only one of
424
+ * those isn't falsely vetoed as "identical". `role` is trimmed and `isolation` is
425
+ * collapsed to just "worktree" vs. everything else — normalized HERE, not by the caller
426
+ * — so a record() fed a normalized spec (e.g. the async fan-out, post-`specOf()`) and a
427
+ * vet() fed raw params (e.g. `isolation: "none"`, an untrimmed `role`) key IDENTICALLY;
428
+ * otherwise the 2-strike veto could silently never fire for that class of retry. */
429
+ private key(t: DelegationKeyInput): string {
430
+ const SEP = "\x1f"; // collision-proof (no field can contain it) without making git see the file as binary (NUL did)
431
+ const base = `${t.agent}${SEP}${t.model ?? ""}${SEP}${t.task}`;
432
+ if (!this.ledgerV2) return base;
433
+ const role = t.role?.trim() ?? "";
434
+ const toolsKey = t.tools === undefined ? "<session-default>" : JSON.stringify([...t.tools].sort());
435
+ const isolation = t.isolation === "worktree" ? "worktree" : "";
436
+ return `${base}${SEP}${role}${SEP}${toolsKey}${SEP}${isolation}`;
437
+ }
438
+
439
+ /** Record a settled delegation; success clears the key (it may be retried again later). */
440
+ record(t: DelegationKeyInput, ok: boolean): void {
441
+ const k = this.key(t);
442
+ if (ok) {
443
+ this.failures.delete(k);
444
+ return;
445
+ }
446
+ this.failures.set(k, (this.failures.get(k) ?? 0) + 1);
447
+ // Bounded: evict the oldest key (Map preserves insertion order).
448
+ if (this.failures.size > this.maxKeys) {
449
+ const oldest = this.failures.keys().next().value;
450
+ if (oldest !== undefined) this.failures.delete(oldest);
451
+ }
452
+ }
453
+
454
+ /** A veto message when any task already failed identically MAX times, else undefined. */
455
+ vet(tasks: DelegationKeyInput[]): string | undefined {
456
+ const blocked = tasks.filter((t) => (this.failures.get(this.key(t)) ?? 0) >= MAX_IDENTICAL_FAILURES);
457
+ if (blocked.length === 0) return undefined;
458
+ const who = blocked.map((t) => `"${t.agent}"${t.model ? ` (${t.model})` : ""}`).join(", ");
459
+ return (
460
+ `delegate: this exact delegation to ${who} already failed ${MAX_IDENTICAL_FAILURES}× — nothing was spawned. ` +
461
+ "Change the approach: a different model, a reworded/smaller task, another agent — or report the blocker to the user."
462
+ );
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Is a settled delegation EVIDENCE against re-issuing it — i.e. worth a strike toward the
468
+ * {@link MAX_IDENTICAL_FAILURES} permanent veto? Two outcomes are terminal without being evidence:
469
+ * an operator abort is cancellation, and an engine REJECTION is the harness breaking (see
470
+ * {@link isInfrastructureFailure}) — a full disk or a crashed adapter says nothing about whether the
471
+ * delegation was a good idea, and two of them would blacklist a task that was never really attempted.
472
+ * Lives beside the ledger so every path that records feeds it the same notion of evidence: the sync
473
+ * fan-out settles a rejection as `ok:false` while the single and async paths let it propagate, and
474
+ * without this the identical infrastructure crash would count in one path and vanish in the others.
475
+ */
476
+ export function shouldRecordDelegationOutcome(result: Partial<InfrastructureFailure>): boolean {
477
+ return result.failureKind !== "abort" && !isInfrastructureFailure(result);
478
+ }
479
+
480
+ /** Quote each name for an inline error list: `"a", "b"`. */
481
+ function quoted(names: string[]): string {
482
+ return names.map((n) => `"${n}"`).join(", ");
483
+ }
484
+
485
+ /**
486
+ * Pre-spawn agent validation for the `delegate` tool — mirrors the model-name path
487
+ * (extension.ts's `resolveDelegateModels`): a wrong agent name must return a SELF-CORRECTING
488
+ * error naming the installed agents BEFORE anything spawns, instead of the engine's late bare
489
+ * failure. Nothing spawns ⇒ nothing is recorded, so a typo never counts toward the
490
+ * {@link DelegationLedger} veto. Empty registry ⇒ the fresh-install guidance (opt-in seeding
491
+ * means a new install has zero agents while the tool is already advertised).
492
+ */
493
+ export function unknownAgentError(requested: string[], installed: string[]): string | undefined {
494
+ const unknown = [...new Set(requested.filter((n) => !installed.includes(n)))];
495
+ if (unknown.length === 0) return undefined;
496
+ const who = quoted(unknown);
497
+ if (installed.length === 0) {
498
+ return (
499
+ `delegate: no sub-agents are installed, so ${who} cannot run — nothing was spawned. ` +
500
+ "Ask the user to run `/persona seed` once (it installs the bundled agents), or add agent files under `.pi/agents/`."
501
+ );
502
+ }
503
+ return (
504
+ `delegate: unknown agent(s) ${who} — nothing was spawned. Installed agents: ${cappedList(installed, 16)}. ` +
505
+ "Pick one of those, or shape `operator` on the fly with `role` + `skills`."
506
+ );
507
+ }
508
+
509
+ /** Pre-spawn contract validation (same all-or-nothing rule as `unknownAgentError`): a spec that
510
+ * names an output contract that is not installed is rejected before ANY leg spawns, with the
511
+ * installed names so the caller can self-correct — and never counts as a ledger strike. */
512
+ export function unknownContractError(requested: Array<string | undefined>, installed: string[]): string | undefined {
513
+ const wanted = [...new Set(requested.map((n) => n?.trim()).filter((n): n is string => Boolean(n)))];
514
+ const unknown = wanted.filter((n) => !installed.includes(n));
515
+ if (unknown.length === 0) return undefined;
516
+ return (
517
+ `delegate: unknown output contract(s) ${quoted(unknown)} — nothing was spawned. Installed contracts: ${cappedList(installed, 16)}. ` +
518
+ "`outputContract` takes a contract NAME; omit it for free-form output and describe the report shape in `requiredArtifacts`."
519
+ );
520
+ }
521
+
522
+ /**
523
+ * Should this `delegate` call run in the BACKGROUND (async)? An explicit `async` always wins;
524
+ * otherwise an interactive session (`hasUI`) defaults to background — the supervisor stays free and
525
+ * results return as follow-ups — unless the call opts out with `sync: true`. A headless run (no UI,
526
+ * e.g. `pi -p`) defaults to SYNC: the single turn must carry the result, and nothing drains a
527
+ * follow-up after the process exits. `renderCall` passes `hasUI: true` (it only ever fires in a UI),
528
+ * so the async tag it shows tracks the exact same default the execute path takes.
529
+ */
530
+ export function wantsAsyncRun(params: { async?: boolean; sync?: boolean }, hasUI: boolean): boolean {
531
+ return params.async ?? (hasUI && params.sync !== true);
532
+ }
533
+
534
+ function viewOf(agent: string, label: string, r: AgentResult): DelegateView {
535
+ return {
536
+ agent,
537
+ label,
538
+ running: false,
539
+ ok: r.ok,
540
+ ...(r.failureKind !== undefined ? { failureKind: r.failureKind } : {}),
541
+ output: r.ok ? r.output || "(no output)" : r.error ?? "(failed)",
542
+ activity: "",
543
+ usage: r.usage,
544
+ };
545
+ }
546
+
547
+ export async function runDelegate(
548
+ params: DelegateParams,
549
+ engine: StrategyEngine,
550
+ limits: DelegateLimits = { maxConcurrency: 4, maxChildren: 8 },
551
+ onProgress?: (views: DelegateView[]) => void,
552
+ onLegStart?: (index: number, abort: () => void) => void,
553
+ onLegSteerable?: (index: number, steer: SteerFn) => void,
554
+ /** The RUN-level abort signal — the same one the caller baked into its engine. A whole-run
555
+ * cancellation reaches a leg through THIS, never through the per-leg controller below, so
556
+ * without it a cancelled leg whose engine rejects is filed as an agent failure and the user's
557
+ * own stop renders as FAILED. Mirrors `SDKDeps.signal`, which classifies the same way. */
558
+ signal?: AbortSignal,
559
+ /** Authoritative per-leg tool lifecycle; arguments and output are intentionally absent. */
560
+ onLegToolEvent?: (index: number, event: ToolEvent) => void,
561
+ ): Promise<DelegateOutcome> {
562
+ if (params.tasks && params.tasks.length > 0) {
563
+ // Enforce the hard ceilings: cap the fan-out and clamp the concurrency the
564
+ // LLM requested — a strategy/tool call cannot exceed the declared limits.
565
+ const tasks = params.tasks.slice(0, limits.maxChildren);
566
+ const dropped = params.tasks.length - tasks.length;
567
+ const concurrency = normalizeDelegateConcurrency(params.concurrency, limits.maxConcurrency);
568
+ if (concurrency > 1) {
569
+ const writeSetError = validateParallelWriteSets(tasks);
570
+ if (writeSetError) return { text: writeSetError, results: [], views: [], ok: false };
571
+ }
572
+ const labels = tasks.map((t, i) => labelFor(t, i));
573
+ const views: DelegateView[] = tasks.map((t, i) => ({
574
+ agent: t.agent,
575
+ label: labels[i] as string,
576
+ running: true,
577
+ ok: false,
578
+ output: "",
579
+ activity: "",
580
+ usage: emptyUsage(),
581
+ }));
582
+ onProgress?.(views.map((v) => ({ ...v })));
583
+
584
+ const results = await mapWithConcurrency(tasks, concurrency, async (t, i) => {
585
+ const ac = new AbortController();
586
+ onLegStart?.(i, () => ac.abort());
587
+ // An engine that REJECTS becomes this leg's own ok:false result: the fan-out's completed
588
+ // siblings (already spawned and billed) must still reach the supervisor. Nothing in here
589
+ // is run-fatal — the max-children ceiling was applied to `tasks` before anything spawned.
590
+ let r: AgentResult;
591
+ try {
592
+ r = await engine.run(
593
+ specOf(t, i),
594
+ (p) => {
595
+ // Stream the leg's rolling output + current tool activity.
596
+ if (p.toolEvent) onLegToolEvent?.(i, p.toolEvent);
597
+ const cur = views[i] as DelegateView;
598
+ views[i] = { ...cur, output: p.output || cur.output, activity: p.activity ?? "" };
599
+ onProgress?.(views.map((v) => ({ ...v })));
600
+ },
601
+ ac.signal,
602
+ (steer) => onLegSteerable?.(i, steer),
603
+ );
604
+ } catch (err) {
605
+ r = legFailure(t.agent, err, ac.signal.aborted || (signal?.aborted ?? false));
606
+ }
607
+ views[i] = viewOf(t.agent, labels[i] as string, r);
608
+ onProgress?.(views.map((v) => ({ ...v })));
609
+ return r;
610
+ });
611
+ const agg = aggregateResults(results);
612
+ const text =
613
+ dropped > 0
614
+ ? `${agg.output}\n\n[pi-persona] ${dropped} task(s) beyond the max-children limit (${limits.maxChildren}) were dropped.`
615
+ : agg.output;
616
+ return { text, results, views, ok: agg.ok };
617
+ }
618
+
619
+ if (params.agent && params.task) {
620
+ const single: DelegateTask = { agent: params.agent, task: params.task };
621
+ if (params.name) single.name = params.name;
622
+ if (params.skills && params.skills.length > 0) single.skills = params.skills;
623
+ if (params.model) single.model = params.model;
624
+ if (params.tools !== undefined) single.tools = params.tools;
625
+ if (params.role?.trim()) single.role = params.role.trim();
626
+ if (params.isolation !== undefined) single.isolation = params.isolation;
627
+ if (params.mcp !== undefined) single.mcp = params.mcp;
628
+ if (params.timeoutMs !== undefined) single.timeoutMs = params.timeoutMs;
629
+ if (params.brief !== undefined) single.brief = params.brief;
630
+ if (params.writeSet !== undefined) single.writeSet = params.writeSet;
631
+ if (params.outputContract !== undefined) single.outputContract = params.outputContract;
632
+ const label = labelFor(single, 0);
633
+ const view: DelegateView = {
634
+ agent: params.agent,
635
+ label,
636
+ running: true,
637
+ ok: false,
638
+ output: "",
639
+ activity: "",
640
+ usage: emptyUsage(),
641
+ };
642
+ onProgress?.([{ ...view }]);
643
+ const ac = new AbortController();
644
+ onLegStart?.(0, () => ac.abort());
645
+ const r = await engine.run(
646
+ specOf(single),
647
+ (p) => {
648
+ if (p.toolEvent) onLegToolEvent?.(0, p.toolEvent);
649
+ if (p.output) view.output = p.output;
650
+ view.activity = p.activity ?? "";
651
+ onProgress?.([{ ...view }]);
652
+ },
653
+ ac.signal,
654
+ (steer) => onLegSteerable?.(0, steer),
655
+ );
656
+ const text = r.ok ? r.output || "(no output)" : `Agent "${params.agent}" failed: ${r.error ?? "(no detail)"}`;
657
+ return { text, results: [r], views: [viewOf(params.agent, label, r)], ok: r.ok };
658
+ }
659
+
660
+ return { text: "delegate: provide either { agent, task } or { tasks: [{ agent, task }, ...] }", results: [], views: [], ok: false };
661
+ }