@aeondave/pi-persona 1.17.0 → 1.17.3

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 (109) hide show
  1. package/AGENTS.md +79 -29
  2. package/CHANGELOG.md +264 -0
  3. package/README.md +27 -12
  4. package/agents/architect.md +21 -0
  5. package/agents/balthasar.md +17 -22
  6. package/agents/casper.md +17 -23
  7. package/agents/evidence-verifier.md +31 -16
  8. package/agents/melchior.md +17 -22
  9. package/agents/operator.md +15 -55
  10. package/agents/research.md +14 -48
  11. package/agents/reviewer.md +20 -39
  12. package/agents/scout.md +14 -15
  13. package/agents/verifier.md +15 -16
  14. package/docs/ARCHITECTURE.md +223 -57
  15. package/docs/EXPERIENCE.md +19 -9
  16. package/docs/MONITORS.md +62 -3
  17. package/docs/REFERENCE.md +189 -35
  18. package/docs/SPINE.md +2 -2
  19. package/docs/STRATEGIES.md +23 -8
  20. package/docs/TELEMETRY.md +1 -0
  21. package/flows/deep-review.flow.json +1 -1
  22. package/package.json +9 -8
  23. package/personas/audit.md +28 -17
  24. package/personas/dev.md +30 -32
  25. package/personas/elite.md +223 -221
  26. package/personas/judge.md +25 -19
  27. package/personas/magi.md +26 -29
  28. package/personas/planner.md +31 -24
  29. package/personas/researcher.md +25 -26
  30. package/personas/swarm.md +35 -25
  31. package/personas/verify.md +30 -19
  32. package/src/agents/agent.ts +20 -0
  33. package/src/bridge.ts +3 -2
  34. package/src/core/brief.ts +429 -356
  35. package/src/core/config.ts +16 -11
  36. package/src/core/model-catalog.ts +171 -0
  37. package/src/core/model-tier.ts +89 -0
  38. package/src/core/models.ts +13 -4
  39. package/src/core/monitor-output.ts +85 -0
  40. package/src/core/monitor.ts +34 -2
  41. package/src/core/nudge.ts +176 -23
  42. package/src/core/seed.ts +99 -6
  43. package/src/core/session-runs.ts +87 -0
  44. package/src/core/settings.ts +251 -0
  45. package/src/core/timer.ts +34 -9
  46. package/src/engine/adapter.ts +12 -8
  47. package/src/engine/async.ts +195 -136
  48. package/src/engine/child.ts +1 -1
  49. package/src/engine/host-module.ts +12 -0
  50. package/src/engine/inproc.ts +11 -6
  51. package/src/engine/stream.ts +3 -0
  52. package/src/exocom/install.ts +2 -1
  53. package/src/extension/companion-settings.ts +29 -0
  54. package/src/extension/engine.ts +43 -16
  55. package/src/extension/hooks.ts +106 -36
  56. package/src/extension/persona-model-settings.ts +336 -0
  57. package/src/extension/persona-settings.ts +459 -0
  58. package/src/extension/shared.ts +15 -7
  59. package/src/extension.ts +642 -267
  60. package/src/monitor/archive.ts +235 -0
  61. package/src/monitor/session.ts +21 -1
  62. package/src/orchestration/flow-journal.ts +26 -5
  63. package/src/orchestration/flow-run.ts +38 -13
  64. package/src/orchestration/flow.ts +9 -2
  65. package/src/orchestration/reducers.ts +9 -5
  66. package/src/orchestration/render.ts +1 -1
  67. package/src/orchestration/sdk.ts +17 -3
  68. package/src/orchestration/strategies/compete.ts +5 -1
  69. package/src/orchestration/strategies/council-rounds.ts +2 -0
  70. package/src/orchestration/strategies/critic-loop.ts +3 -0
  71. package/src/orchestration/strategies/debate.ts +2 -0
  72. package/src/orchestration/strategies/fanout.ts +3 -2
  73. package/src/orchestration/strategies/judge.ts +5 -1
  74. package/src/orchestration/strategies/magi.ts +40 -23
  75. package/src/orchestration/strategies/map.ts +13 -3
  76. package/src/orchestration/strategies/pair.ts +2 -0
  77. package/src/orchestration/strategies/pipeline.ts +2 -0
  78. package/src/orchestration/strategies/synthesize.ts +27 -7
  79. package/src/orchestration/strategy.ts +31 -0
  80. package/src/persona/config-store.ts +6 -195
  81. package/src/persona/model-participants.ts +1 -1
  82. package/src/persona/persona.ts +7 -3
  83. package/src/persona/settings-store.ts +636 -0
  84. package/src/telemetry/contract.ts +1 -1
  85. package/src/tools/background-run.ts +151 -0
  86. package/src/tools/council.ts +77 -52
  87. package/src/tools/delegate-tool.ts +315 -285
  88. package/src/tools/delegate.ts +22 -210
  89. package/src/tools/exocom-work.ts +18 -53
  90. package/src/tools/exocom.ts +15 -19
  91. package/src/tools/flow.ts +85 -28
  92. package/src/tools/intercom-tool.ts +108 -118
  93. package/src/tools/intercom.ts +1 -1
  94. package/src/tools/models.ts +19 -9
  95. package/src/tools/monitor.ts +39 -6
  96. package/src/tools/timer.ts +11 -13
  97. package/src/ui/agent-overlay.ts +55 -14
  98. package/src/ui/agent-tree.ts +84 -18
  99. package/src/ui/compact-widget.ts +25 -0
  100. package/src/ui/failure-summary.ts +55 -0
  101. package/src/ui/literal-tool-result.ts +24 -0
  102. package/src/ui/model-picker.ts +158 -81
  103. package/src/ui/persona-settings.ts +328 -0
  104. package/src/ui/presentation.ts +24 -0
  105. package/src/ui/report-body.ts +63 -0
  106. package/src/ui/session-cards.ts +189 -0
  107. package/src/ui/wake-rows.ts +262 -0
  108. package/teams.yaml +5 -1
  109. package/src/persona/state.ts +0 -28
@@ -1,33 +1,33 @@
1
1
  /** `delegate` tool registration. */
2
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import type { AgentToolUpdateCallback, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import type { AgentConfig } from "../agents/agent.ts";
4
4
  import { Type } from "typebox";
5
+ import { END_TURN_NOTE } from "../core/brief.ts";
5
6
  import { fenceUntrusted } from "../core/fence.ts";
6
7
  import { Text, visibleWidth } from "@earendil-works/pi-tui";
7
- import { Container, Spacer } from "@earendil-works/pi-tui";
8
8
  import { configuredModels } from "../extension/engine.ts";
9
- import { compactInlineText, MAX_COLLAPSED_CARD_COLUMNS, sanitizeTerminalText } from "../ui/presentation.ts";
9
+ import { compactInlineText, compactVisibleLine, MAX_COLLAPSED_CARD_COLUMNS, sanitizeTerminalText } from "../ui/presentation.ts";
10
10
  import { compactVisibleText } from "../ui/presentation.ts";
11
+ import { createReportBody } from "../ui/report-body.ts";
11
12
  import { resolveModelRef } from "../core/models.ts";
12
13
  import { inventedLegNameHint } from "../core/naming.ts";
13
- import { formatUsage, toolUsageField, type ChildUsageLedger } from "../ui/usage.ts";
14
- import { sumUsage } from "../orchestration/reducers.ts";
15
- import { expandDetailHint, failureDetails } from "../extension/shared.ts";
14
+ import { toolUsageField } from "../ui/usage.ts";
15
+ import { expandDetailHint, failureDetails, isInline } from "../extension/shared.ts";
16
16
  import {
17
- DelegationLedger, type DelegateView, type DelegateParams as DelegateCall, nameFor, normalizeDelegateConcurrency,
18
- runDelegate, shortModel, shouldRecordDelegationOutcome, specOf, unknownAgentError, unknownContractError,
19
- validateDelegationBrief, validateParallelWriteSets, wantsAsyncRun, coerceDelegateParams,
17
+ DelegationLedger, type DelegateParams as DelegateCall, keyInputOf, nameFor, normalizeDelegateConcurrency,
18
+ shortModel, shouldRecordDelegationOutcome, specOf, unknownAgentError, unknownContractError,
19
+ validateDelegationBrief, validateParallelWriteSets, coerceDelegateParams,
20
20
  } from "./delegate.ts";
21
21
  import { Semaphore } from "../orchestration/parallel.ts";
22
22
  import type { AgentRunSpec, SteerFn, StrategyEngine } from "../orchestration/sdk.ts";
23
23
  import type { AgentResult } from "../orchestration/types.ts";
24
- import { agentNodeStatusForDelegate, sanitizeLabel } from "../extension/shared.ts";
24
+ import { sanitizeLabel } from "../extension/shared.ts";
25
25
  import type { PersonaController } from "../persona/controller.ts";
26
26
  import type { AgentTree } from "../ui/agent-tree.ts";
27
- import { runDisplayName, type AsyncRunTracker } from "../engine/async.ts";
27
+ import { buildCompletionReport, runDisplayName, type AsyncRunTracker } from "../engine/async.ts";
28
+ import type { BackgroundRuns } from "./background-run.ts";
28
29
  import { emptyUsage, type ProgressSnapshot, type ToolEvent } from "../engine/stream.ts";
29
30
  import { progressPatch } from "../ui/agent-tree.ts";
30
- import type { AddNodeInput } from "../ui/agent-tree.ts";
31
31
  import type { RunLimits } from "../core/capabilities.ts";
32
32
 
33
33
  interface DelegateLaunchSnapshot {
@@ -39,12 +39,22 @@ interface DelegateLaunchSnapshot {
39
39
 
40
40
  interface DelegateLaunchRow {
41
41
  id: string;
42
- fullName: string;
43
- displayName: string;
42
+ label: string;
43
+ detailLabel: string;
44
+ agent: string;
45
+ model?: string;
46
+ displayLabel: string;
44
47
  clipped: boolean;
45
48
  hasAlias: boolean;
46
49
  }
47
50
 
51
+ interface CollapsedLaunchLine {
52
+ prefix: string;
53
+ label: string;
54
+ metadata: string;
55
+ suffix: string;
56
+ }
57
+
48
58
  const COLLAPSED_LAUNCH_ROWS = 3;
49
59
 
50
60
  /** Prefer session-persisted identity, then recover old launch cards from the live tracker. */
@@ -98,51 +108,93 @@ function launchRows(
98
108
  });
99
109
  const named = entries.map(({ run, hasAlias }) => ({
100
110
  id: compactInlineText(run.id, { maxChars: 64 }) || "run",
111
+ label: sanitizeTerminalText(run.label),
112
+ agent: sanitizeTerminalText(run.agent),
113
+ ...(run.model ? { model: sanitizeTerminalText(run.model) } : {}),
101
114
  fullName: runDisplayName(run),
102
115
  hasAlias,
103
116
  }));
104
117
  const counts = new Map<string, number>();
105
118
  for (const row of named) counts.set(row.fullName, (counts.get(row.fullName) ?? 0) + 1);
106
119
  return named.map((row) => {
107
- const displayName = counts.get(row.fullName)! > 1
108
- ? `${compactInlineText(row.fullName, { maxChars: 56 })} [${compactInlineText(row.id, { maxChars: 16 })}]`
109
- : row.fullName;
120
+ const duplicateSuffix = counts.get(row.fullName)! > 1 ? ` [${compactInlineText(row.id, { maxChars: 16 })}]` : "";
121
+ const detailLabel = `${row.label}${duplicateSuffix}`;
122
+ const displayLabel = compactInlineText(detailLabel, { maxChars: 80 });
123
+ const displayMetadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
110
124
  return {
111
125
  ...row,
112
- displayName,
113
- clipped: compactInlineText(displayName, { maxChars: 80 }) !== displayName,
126
+ detailLabel,
127
+ displayLabel,
128
+ clipped: displayLabel !== detailLabel || compactInlineText(displayMetadata, { maxChars: 80 }) !== displayMetadata,
114
129
  };
115
130
  });
116
131
  }
117
132
 
118
- function collapsedLaunchSummary(rows: readonly DelegateLaunchRow[], dropped: number | undefined): string {
119
- const prefix = "delegate launched ";
120
- const dropNote = dropped && dropped > 0 ? ` · ${dropped} task${dropped === 1 ? "" : "s"} dropped` : "";
121
- const maxVisible = Math.min(rows.length, COLLAPSED_LAUNCH_ROWS);
122
- for (let count = maxVisible; count >= 1; count--) {
123
- const names = rows.slice(0, count).map((row) => row.displayName);
124
- const omitted = rows.length - names.length;
125
- const suffix = omitted > 0 ? `, +${omitted} more` : "";
126
- const body = `${prefix}${names.join(", ")}${suffix}${dropNote}`;
127
- const needsHint = omitted > 0 || rows.slice(0, count).some((row) => row.clipped) || body.length > MAX_COLLAPSED_CARD_COLUMNS || visibleWidth(body) > MAX_COLLAPSED_CARD_COLUMNS;
128
- const candidate = `${body}${needsHint ? ` · ${expandDetailHint()}` : ""}`;
129
- if (candidate.length <= MAX_COLLAPSED_CARD_COLUMNS && visibleWidth(candidate) <= MAX_COLLAPSED_CARD_COLUMNS) return candidate;
130
- }
131
- const omitted = rows.length - 1;
132
- const suffix = omitted > 0 ? `, +${omitted} more` : "";
133
- const hint = ` · ${expandDetailHint()}`;
134
- const fixed = `${prefix}${suffix}${dropNote}${hint}`;
135
- const aliasLimit = Math.max(16, Math.min(MAX_COLLAPSED_CARD_COLUMNS - visibleWidth(fixed), MAX_COLLAPSED_CARD_COLUMNS - fixed.length));
136
- const first = compactInlineText(rows[0]!.displayName, { maxChars: aliasLimit });
137
- return compactInlineText(`${prefix}${first}${suffix}${dropNote}${hint}`, { maxChars: MAX_COLLAPSED_CARD_COLUMNS });
133
+ function collapsedLaunchRows(rows: readonly DelegateLaunchRow[], dropped: number | undefined): CollapsedLaunchLine[] {
134
+ if (rows.length === 0) return [{ prefix: "launched ", label: "", metadata: "", suffix: "" }];
135
+ const visibleCount = Math.min(rows.length, COLLAPSED_LAUNCH_ROWS);
136
+ const omitted = rows.length - visibleCount;
137
+ const droppedCount = typeof dropped === "number" && Number.isFinite(dropped) ? Math.max(0, Math.floor(dropped)) : 0;
138
+ const suffixParts = [
139
+ ...(omitted > 0 ? [`+${omitted} more`] : []),
140
+ ...(droppedCount > 0 ? [`${droppedCount} task${droppedCount === 1 ? "" : "s"} dropped`] : []),
141
+ ];
142
+ const hintText = compactInlineText(expandDetailHint(), { maxChars: 32 });
143
+ const rowsNeedHint = rows.slice(0, visibleCount).some((row, index) => {
144
+ const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
145
+ const prefixWidth = index === 0 ? visibleWidth("launched ") : visibleWidth(" ");
146
+ return row.clipped || prefixWidth + visibleWidth(row.displayLabel) + (metadata ? 3 + visibleWidth(metadata) : 0) > MAX_COLLAPSED_CARD_COLUMNS;
147
+ });
148
+ if (omitted > 0 || droppedCount > 0 || rowsNeedHint) suffixParts.push(hintText);
149
+ const suffix = suffixParts.length > 0 ? ` · ${compactInlineText(suffixParts.join(" · "), { maxChars: 56 })}` : "";
150
+ return rows.slice(0, visibleCount).map((row, index) => {
151
+ const prefix = index === 0 ? "launched " : " ";
152
+ const fixedWidth = visibleWidth(prefix) + (index === visibleCount - 1 ? visibleWidth(suffix) : 0);
153
+ const budget = Math.max(16, MAX_COLLAPSED_CARD_COLUMNS - fixedWidth);
154
+ const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
155
+ const metadataBudget = Math.min(36, visibleWidth(metadata));
156
+ const labelBudget = Math.max(16, budget - (metadata ? metadataBudget + 3 : 0));
157
+ const label = compactInlineText(row.displayLabel, { maxChars: labelBudget });
158
+ const separator = metadata && budget - visibleWidth(label) >= 19 ? " · " : "";
159
+ const availableMetadata = budget - visibleWidth(label) - visibleWidth(separator);
160
+ const shownMetadata = separator && availableMetadata >= 16
161
+ ? compactInlineText(metadata, { maxChars: availableMetadata })
162
+ : "";
163
+ const lineSuffix = index === visibleCount - 1 ? suffix : "";
164
+ return { prefix, label, metadata: shownMetadata, suffix: lineSuffix };
165
+ });
138
166
  }
139
167
 
140
168
  function expandedLaunchDetails(text: string, rows: readonly DelegateLaunchRow[]): string {
141
169
  if (rows.length === 0 || !rows.some((row) => row.hasAlias)) return text;
142
- const diagnostics = rows.map((row) => `- ${row.fullName} [${row.id}]`).join("\n");
170
+ const diagnostics = rows.map((row) => `- ${[row.detailLabel, row.agent, row.model].filter((value): value is string => !!value).join(" · ")} [${row.id}]`).join("\n");
143
171
  return `${text}\n\nLaunched workers:\n${diagnostics}`;
144
172
  }
145
173
 
174
+ function matchesCompleteSingleCall(args: unknown, argsComplete: boolean, isPartial: boolean, row: DelegateLaunchRow): boolean {
175
+ if (!argsComplete || isPartial || !row.hasAlias) return false;
176
+ const coerced = coerceDelegateParams(args);
177
+ if (!coerced.ok || coerced.params.tasks || !coerced.params.agent || !coerced.params.task?.trim()) return false;
178
+ const params = coerced.params;
179
+ const agent = params.agent;
180
+ if (typeof agent !== "string") return false;
181
+ const expectedLabel = nameFor({ agent, ...(params.name ? { name: params.name } : {}) }, 0);
182
+ return row.label === expectedLabel
183
+ && row.agent === agent
184
+ && (row.model ?? "") === shortModel(params.model);
185
+ }
186
+
187
+ function singleLaunchReceipt(row: DelegateLaunchRow): string[] {
188
+ const prefix = "launched ";
189
+ const suffix = row.clipped ? ` · ${compactInlineText(expandDetailHint(), { maxChars: 32 })}` : "";
190
+ const labelBudget = Math.max(16, MAX_COLLAPSED_CARD_COLUMNS - visibleWidth(prefix) - visibleWidth(suffix));
191
+ const label = compactInlineText(row.displayLabel, { maxChars: labelBudget });
192
+ const rows = [compactVisibleLine(`${prefix}${label}${suffix}`, MAX_COLLAPSED_CARD_COLUMNS)];
193
+ const metadata = [row.agent, row.model].filter((value): value is string => !!value).join(" · ");
194
+ if (metadata) rows.push(compactVisibleLine(` ${compactInlineText(metadata, { maxChars: MAX_COLLAPSED_CARD_COLUMNS - 2 })}`, MAX_COLLAPSED_CARD_COLUMNS));
195
+ return rows;
196
+ }
197
+
146
198
  export interface DelegateToolDeps {
147
199
  get lastCtx(): ExtensionContext | undefined;
148
200
  set lastCtx(value: ExtensionContext | undefined);
@@ -151,21 +203,18 @@ export interface DelegateToolDeps {
151
203
  contractNames: () => string[];
152
204
  buildEngine: (signal?: AbortSignal, onProgress?: (s: ProgressSnapshot) => void, opts?: { async?: boolean }) => StrategyEngine;
153
205
  agentTree: AgentTree;
154
- nextRootId: (prefix: string) => string;
155
206
  tracker: AsyncRunTracker;
207
+ /** Headless: joins the runs a call launched (see background-run.ts). */
208
+ runs: BackgroundRuns;
209
+ /** The launch pool, shared with the background `council` and `flow` runs (absent ⇒ a private one). */
210
+ launchPool?: Semaphore;
156
211
  config: { ledgerV2: boolean };
157
212
  RUN_LIMITS: RunLimits;
158
213
  publishAgentTool(agentId: string, event: ToolEvent): void;
159
214
  stopRegistry: Map<string, () => void>;
160
215
  steerRegistry: Map<string, SteerFn>;
161
- stopRequested: Set<string>;
162
- ensurePersonaModels(ctx: ExtensionContext, roster: unknown[]): Promise<void>;
163
- clearStops(prefix: string): void;
164
- clearSteers(prefix: string): void;
165
216
  drainBusBlock(): string;
166
217
  startPeek(): void;
167
- childUsage: ChildUsageLedger;
168
- publishPersonaCost(): void;
169
218
  }
170
219
 
171
220
  export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): void {
@@ -205,7 +254,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
205
254
  skills: Type.Optional(Type.Union([SkillsSchema, JsonString])),
206
255
  role: Type.Optional(RoleSchema),
207
256
  model: Type.Optional(
208
- Type.String({ description: "Model override (exact provider/id — call the `models` tool to find one)" }),
257
+ Type.String({ description: "Optional exact provider/id. Prefer cached nearby family/generation candidates; use `models` for targeted lookup or refresh only when needed. Omission retains saved/agent selections and defaults; an explicit current supervisor provider/id selects it deliberately, maintaining provider/billing authorization. Explicit user selections win." }),
209
258
  ),
210
259
  tools: Type.Optional(Type.Union([Type.Array(Type.String(), { description: "Tool allowlist override for this sub-agent; [] explicitly grants no tools" }), JsonString])),
211
260
  isolation: Type.Optional(
@@ -215,7 +264,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
215
264
  Type.Boolean({ description: "true = give this sub-agent working MCP tools (runs it on the child engine so pi-mcp-adapter initializes; the default engine leaves MCP tools 'not initialized'). Pass any server session id in the task to share a server-keyed backend's state." }),
216
265
  ),
217
266
  timeoutMs: Type.Optional(
218
- Type.Number({ description: "Idle timeout in ms for this worker: resets on progress, not a total runtime cap. A positive value overrides the shared default; zero or negative uses the default." }),
267
+ Type.Number({ description: "Idle window in ms for this worker, reset by any progress. NOT a deadline: a worker that keeps progressing runs past it. A positive value overrides the shared default; zero or negative uses the default." }),
219
268
  ),
220
269
  writeSet: Type.Optional(Type.Union([WriteSetSchema, JsonString])),
221
270
  outputContract: Type.Optional(Type.String({ description: "Name of an installed output contract (see /doctor; built-in: default). Omit for free-form output — describe the report shape in requiredArtifacts instead." })),
@@ -229,7 +278,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
229
278
  name: Type.Optional(Type.String({ description: LeaderNameDescription })),
230
279
  skills: Type.Optional(Type.Union([SkillsSchema, JsonString])),
231
280
  role: Type.Optional(RoleSchema),
232
- model: Type.Optional(Type.String({ description: "Model override (single mode)" })),
281
+ model: Type.Optional(Type.String({ description: "Optional exact provider/id (single mode). Prefer cached nearby family/generation candidates; use `models` for targeted lookup or refresh only when needed. Omission retains saved/agent selections and defaults; an explicit current supervisor provider/id selects it deliberately, maintaining provider/billing authorization. Explicit user selections win." })),
233
282
  tools: Type.Optional(Type.Union([Type.Array(Type.String(), { description: "Tool allowlist override (single mode); [] explicitly grants no tools" }), JsonString])),
234
283
  isolation: Type.Optional(
235
284
  Type.Union([Type.Literal("none"), Type.Literal("worktree")], { description: "worktree = run the single sub-agent in an isolated git worktree" }),
@@ -238,7 +287,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
238
287
  Type.Boolean({ description: "true = give the single sub-agent working MCP tools (runs it on the child engine; the default engine leaves MCP tools 'not initialized')" }),
239
288
  ),
240
289
  timeoutMs: Type.Optional(
241
- Type.Number({ description: "Single-mode idle timeout in ms: resets on progress, not a total runtime cap. A positive value overrides the shared default; zero or negative uses the default." }),
290
+ Type.Number({ description: "Single-mode idle window in ms, reset by any progress. NOT a deadline: a worker that keeps progressing runs past it. A positive value overrides the shared default; zero or negative uses the default." }),
242
291
  ),
243
292
  writeSet: Type.Optional(Type.Union([WriteSetSchema, JsonString])),
244
293
  outputContract: Type.Optional(Type.String({ description: "Name of an installed output contract (see /doctor; built-in: default). Omit for free-form output — describe the report shape in requiredArtifacts instead." })),
@@ -251,18 +300,6 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
251
300
  concurrency: Type.Optional(
252
301
  Type.Integer({ minimum: 1, description: `Max children to run at once (default ${d.RUN_LIMITS.maxConcurrency}; larger requests are clamped)` }),
253
302
  ),
254
- async: Type.Optional(
255
- Type.Boolean({
256
- description:
257
- "Explicitly run in the background (already the DEFAULT in interactive sessions) — returns run ids at once; each result comes back to you automatically as a follow-up. Set false to force blocking. If both async and sync are supplied, async takes precedence.",
258
- }),
259
- ),
260
- sync: Type.Optional(
261
- Type.Boolean({
262
- description:
263
- "Block this turn until the sub-agent(s) finish and return their results inline — only when you need them before your very next step. (Headless sessions already default to sync.) Omit async when using this flag; an explicit async value takes precedence.",
264
- }),
265
- ),
266
303
  });
267
304
 
268
305
  // Canonicalise a delegate's requested model names to provider/id; return a clear
@@ -289,11 +326,12 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
289
326
  return undefined;
290
327
  }
291
328
 
292
- // The async launch pool: every background run passes through here, so a 20-task async
293
- // fan-out respects the same concurrency ceiling a sync delegate does, instead of opening
294
- // 20 model sessions at once. Queued runs show as "running" with no progress yet; stopping
295
- // a queued run works (the engine settles a pre-aborted signal without a model call).
296
- const asyncSlots = new Semaphore(d.RUN_LIMITS.maxConcurrency);
329
+ // The launch pool: EVERY delegate run passes through here, headless ones included, so a 20-task
330
+ // fan-out respects one concurrency ceiling instead of opening 20 model sessions at once. Queued
331
+ // runs show as "running" with no progress yet; stopping a queued run works (the engine settles a
332
+ // pre-aborted signal without a model call). A background `council`/`flow` takes ONE slot of this same
333
+ // pool for its whole run (tools/background-run.ts); its inner legs are gated by the strategy SDK's own semaphore.
334
+ const asyncSlots = d.launchPool ?? new Semaphore(d.RUN_LIMITS.maxConcurrency);
297
335
  let asyncNameSequence = 0;
298
336
 
299
337
  // Runtime anti-loop guard: an identical (agent, model, task) delegation that failed
@@ -317,30 +355,89 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
317
355
  return effectiveMcp === true || effectiveTools === undefined || effectiveTools.some((tool) => !readOnlyTools.has(tool));
318
356
  }
319
357
 
320
- // Launch one agent in the background (tracked) and add its live async node to the tree.
321
- // `label` is the bare codename (nameFor) — the model is folded in here (and stored on the
322
- // d.tracker entry) so the tree node and every intercom digest show the SAME composed name.
323
- function launchAsyncRun(agent: string, task: string, runSpec: AgentRunSpec, label: string, batchSlots?: Semaphore): string {
358
+ // Ownership intent belongs to the agent definition, not to the caller. An agent that declares the area
359
+ // it alone writes (`writeSet` in its frontmatter: research → `.research/`) hands that area to each writing leg
360
+ // that names none of its own, so the persona's ownership gate sees a writer with disjoint ownership
361
+ // rather than an unknown one the supervisor would have to guess a fix for. A leg's own non-empty
362
+ // writeSet always wins, and an agent that declares nothing leaves the leg exactly as the caller wrote
363
+ // it. Declarative like the field it fills: the paths annotate and gate, nothing enforces them at runtime.
364
+ function inheritAgentWriteSets(call: DelegateCall): DelegateCall {
365
+ const declared = (agent: string | undefined, own: readonly string[] | undefined): string[] | undefined => {
366
+ if (own?.some((path) => path.trim())) return undefined;
367
+ const set = d.agents.find((candidate) => candidate.name === agent)?.writeSet;
368
+ return set && set.length > 0 ? [...set] : undefined;
369
+ };
370
+ if (call.tasks && call.tasks.length > 0) {
371
+ return {
372
+ ...call,
373
+ tasks: call.tasks.map((task) => {
374
+ const inherited = mayMutateWorkspace(task) ? declared(task.agent, task.writeSet) : undefined;
375
+ return inherited ? { ...task, writeSet: inherited } : task;
376
+ }),
377
+ };
378
+ }
379
+ if (!call.agent || !mayMutateWorkspace({ ...call, agent: call.agent })) return call;
380
+ const inherited = declared(call.agent, call.writeSet);
381
+ return inherited ? { ...call, writeSet: inherited } : call;
382
+ }
383
+
384
+ // An interactive `tasks` fan-out of 2+ legs gets one header row — `delegate · N legs`, detail `k/N settled`
385
+ // — that leaves with its last leg. Settlement only ever updates or removes it, never adds, so a leg that
386
+ // settles after the tree was cleared (/new, shutdown) cannot resurrect it. Legs are counted as they
387
+ // settle (not derived from the tracker), so a leg failing inside launch() cannot close it early.
388
+ const batchRows = new Map<string, { size: number; settled: number }>();
389
+ const batchRowId = (batch: string): string => `delegate:${batch}`;
390
+ function openBatchRow(batch: string, size: number): string {
391
+ batchRows.set(batch, { size, settled: 0 });
392
+ const id = batchRowId(batch);
393
+ d.agentTree.add({ id, label: `delegate · ${size} legs`, kind: "delegate", status: "running", detail: `0/${size} settled` });
394
+ return id;
395
+ }
396
+ d.tracker.onComplete(({ batch }) => {
397
+ if (batch === undefined) return;
398
+ const row = batchRows.get(batch);
399
+ if (!row) return;
400
+ row.settled += 1;
401
+ if (row.settled < row.size) {
402
+ d.agentTree.update(batchRowId(batch), { detail: `${row.settled}/${row.size} settled` });
403
+ return;
404
+ }
405
+ batchRows.delete(batch);
406
+ d.agentTree.update(batchRowId(batch), { status: "done" }); // a node removed while "running" is reported as stopped
407
+ d.agentTree.remove(batchRowId(batch));
408
+ });
409
+
410
+ // Launch one agent (tracked) and add its live async node to the tree. `label` is the bare codename
411
+ // (nameFor) — the model is folded in here (and stored on the d.tracker entry) so the tree node and
412
+ // every intercom digest show the SAME composed name. `inline` (headless) means the launching call
413
+ // stays parked until the run settles, so the leg may not block on a supervisor reply it cannot give.
414
+ // `fanOut` is set for a leg of a `tasks` call: its batch id, the per-call concurrency gate, and the
415
+ // header row it hangs under (interactive fan-outs of 2+ legs only).
416
+ function launchAsyncRun(agent: string, task: string, runSpec: AgentRunSpec, label: string, inline: boolean, fanOut?: { batch: string; slots: Semaphore | undefined; groupId: string | undefined }): string {
324
417
  const model = shortModel(runSpec.model);
325
418
  // The writer classification travels WITH the run (d.tracker metadata), not in a side Set keyed by
326
419
  // the returned id: a thunk that throws synchronously settles the run inside launch(), so a
327
420
  // registration after launch() returns would re-insert an already-dead run and leak it.
328
- const id = d.tracker.launch({ agent, task, label, ...(model ? { model } : {}), mutates: mayMutateWorkspace(runSpec) }, (onProgress, runId) => {
421
+ const id = d.tracker.launch({ agent, task, label, ...(model ? { model } : {}), mutates: mayMutateWorkspace(runSpec), queued: true, ...(fanOut ? { batch: fanOut.batch } : {}) }, (onProgress, runId) => {
329
422
  const nodeId = `async:${runId}`;
330
423
  // A real, HARD stop for the async run (a steer is only a soft request the child may
331
424
  // ignore): aborting this signal makes the engine call the sub-agent's `agent.abort()`.
332
425
  const ac = new AbortController();
333
426
  d.stopRegistry.set(nodeId, () => ac.abort());
334
427
  const execute = () =>
335
- asyncSlots.with(() =>
336
- d.buildEngine(
428
+ asyncSlots.with(() => {
429
+ d.tracker.markAdmitted(runId);
430
+ return d.buildEngine(
337
431
  undefined,
338
432
  (snap) => {
339
433
  onProgress(snap);
340
434
  if (snap.toolEvent) d.publishAgentTool(nodeId, snap.toolEvent);
341
- d.agentTree.update(nodeId, progressPatch(snap, Date.now()));
435
+ const now = Date.now();
436
+ d.agentTree.update(nodeId, progressPatch(snap, now));
437
+ // Any leg advancing is the batch advancing: its header must not show a false stall.
438
+ if (fanOut?.groupId) d.agentTree.update(fanOut.groupId, { lastAdvanceAt: now });
342
439
  },
343
- { async: true },
440
+ { async: !inline },
344
441
  // STOP via `ac.signal` (hard abort) and STEER via the run-id key (soft redirect) —
345
442
  // both work for the supervisor (intercom `stop`/`steer`) and the f9 overlay (`x`/`s`),
346
443
  // for ANY persona (these are supervisor→child controls, not child tools).
@@ -350,30 +447,19 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
350
447
  // the queue-time seed, so the elapsed reading and stall badge are both honest.
351
448
  const now = Date.now();
352
449
  d.agentTree.update(nodeId, { detail: "", startedAt: now, lastAdvanceAt: now });
353
- }),
354
- ac.signal,
355
- );
450
+ });
451
+ }, ac.signal);
356
452
  // A per-call semaphore composes with the process-wide ceiling. Acquire it first so a
357
453
  // queued member of a serial batch never occupies a global slot while waiting for its
358
- // predecessor. This makes `concurrency: 1` mean the same thing in sync and async mode.
359
- return (batchSlots ? batchSlots.with(execute, ac.signal) : execute())
454
+ // predecessor, so `concurrency: 1` means one leg at a time.
455
+ return (fanOut?.slots ? fanOut.slots.with(execute, ac.signal) : execute())
360
456
  .catch((error: unknown): AgentResult => {
361
457
  if (!ac.signal.aborted) throw error;
362
458
  return { agent, output: "", usage: emptyUsage(), ok: false, error: "agent aborted", failureKind: "abort" };
363
459
  })
364
460
  .then((r) => {
365
461
  if (shouldRecordDelegationOutcome(r)) {
366
- ledger.record(
367
- {
368
- agent,
369
- ...(runSpec.model ? { model: runSpec.model } : {}),
370
- task,
371
- ...(runSpec.role ? { role: runSpec.role } : {}),
372
- ...(runSpec.tools !== undefined ? { tools: runSpec.tools } : {}),
373
- ...(runSpec.isolation ? { isolation: runSpec.isolation } : {}),
374
- },
375
- r.ok,
376
- );
462
+ ledger.record(keyInputOf({ ...runSpec, task }), r.ok);
377
463
  }
378
464
  return r;
379
465
  });
@@ -385,36 +471,58 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
385
471
  d.agentTree.add({
386
472
  id: nodeId,
387
473
  label: model ? `${label} · ${model}` : label,
474
+ ...(fanOut?.groupId ? { parentId: fanOut.groupId } : {}),
388
475
  status: "running",
389
476
  kind: "subagent",
390
477
  agent,
391
478
  ...(runSpec.model ? { model: runSpec.model } : {}),
392
479
  detail: "queued",
393
480
  });
394
- d.startPeek(); // arm the async-run status/stall monitor (no-op if both cadences are disabled)
481
+ // Arm the async-run status/stall monitor (no-op if both cadences are disabled). A headless leg runs
482
+ // inline with nobody to read a status card, exactly as the pre-tracker headless path did.
483
+ if (!inline) d.startPeek();
395
484
  return id;
396
485
  }
397
486
 
487
+ // Headless (`pi -p`): nothing drains a follow-up once the process exits, so the call that launched
488
+ // the runs joins them and returns the very report their completion follow-up would have carried.
489
+ async function settleInline(ids: string[], signal: AbortSignal | undefined, onUpdate: AgentToolUpdateCallback | undefined, droppedNote = "") {
490
+ const { runs, usage } = await d.runs.settle(ids, signal, (done, total) => {
491
+ onUpdate?.({ content: [{ type: "text", text: `delegate: ${done}/${total} done` }], details: {} });
492
+ });
493
+ const ok = runs.every((run) => run.status === "done");
494
+ const dropped = droppedNote && `
495
+
496
+ [pi-persona]${droppedNote}`;
497
+ return {
498
+ content: [{ type: "text" as const, text: `${buildCompletionReport(runs, fenceUntrusted, { bounded: false })}${dropped}${d.drainBusBlock()}` }],
499
+ details: ok ? {} : failureDetails({}),
500
+ isError: !ok,
501
+ ...toolUsageField(usage),
502
+ };
503
+ }
504
+
398
505
  pi.registerTool({
399
506
  name: "delegate",
400
507
  label: "Delegate",
401
508
  description: [
402
- "Delegate work to sub-agents — your default move whenever a task has independent, heavy, or parallel parts.",
403
- "Pass agent and task for one worker, or a tasks array for parallel workers. Follow the active persona's delegation policy for required fields.",
404
- "When requireBrief is active, EVERY worker needs brief: { objective, scopeRoe, position, constraints, requiredArtifacts, stopConditions } with nonempty values, including read-only scouts. In parallel mode put it in each tasks[].brief; task prose and a top-level brief do not substitute. Read the current per-turn delegation brief for a complete example.",
405
- "A persona may also require a structured output contract or disjoint writeSet ownership for parallel writers. Incomplete calls are rejected before any worker starts; fill the missing fields across the batch before retrying.",
406
- "In interactive sessions it runs in the BACKGROUND by default: you get run ids at once and stay free.",
407
- "Work independently while children run; when only children remain, end your turn and let their automatic completion follow-ups wake you.",
408
- "Do not monitor, poll, or automatically wait for all children. Use `intercom wait` only when a result is needed before your next step; `sync: true` intentionally blocks instead; headless runs default to sync.",
509
+ "Delegate bounded independent work to supervised sub-agents by default; you own the objective, decisions and result review. Keep quick, trivial or tightly coupled work local when a hand-off would add more overhead than value.",
510
+ "Pass agent and task for one worker, or a tasks array for parallel workers. Required fields follow the active persona's delegation policy (the per-turn delegation brief shows the exact call shape); an incomplete call is rejected before any worker starts, and the error names what is missing.",
511
+ "Interactive sessions ALWAYS run it in the BACKGROUND: you get run ids at once and stay free, and each result wakes you as a completion follow-up (a `tasks` call wakes you ONCE, when its last leg settles). Work independently meanwhile; when only children remain, end your turn. NEVER sleep, poll, or arm a `timer` to wait for a child. Headless sessions (`pi -p`) block for the turn and return results inline.",
409
512
  "Steering received during an active tool may be acted on after that tool returns; do not assume arbitrary tool work is interrupted immediately.",
410
513
  "No fitting agent? Shape one on the fly: `operator` + `role` (extra system prompt) + `skills`.",
411
514
  "Assign each worker's name before launch so it sees the same identity from its first turn; omitted names keep the generic fallback.",
412
- "A `model` may be a loose name ('sonnet') — it resolves to YOUR provider's id; ambiguous names return",
413
- "candidates (or call `models`). Other options: name, tools, outputContract, writeSet, isolation: \"worktree\", mcp, concurrency, tasks[].timeoutMs.",
515
+ "A `model` may be a loose name ('sonnet', 'opus-4.6', 'claude-pro-max-native/opus-4-6') — it resolves to YOUR provider's id; ambiguous names return",
516
+ "candidates. Other options: name, tools, outputContract, writeSet, isolation: \"worktree\", mcp, concurrency, tasks[].timeoutMs.",
414
517
  ].join(" "),
415
518
  parameters: DelegateParams,
416
519
  async execute(_toolCallId, rawParams, signal, onUpdate, ctx) {
417
520
  d.lastCtx = ctx;
521
+ // Interactive sessions (TUI and RPC) ALWAYS run in the background: the supervisor stays free
522
+ // and results return as follow-ups (the idle-gated push path). Headless (`pi -p`) launches the
523
+ // SAME runs but joins them before returning — the single turn must carry the result. A runtime
524
+ // constraint, not a model-facing choice, so there is no flag.
525
+ const inline = isInline(ctx);
418
526
  const coerced = coerceDelegateParams(rawParams);
419
527
  if (!coerced.ok) return { content: [{ type: "text", text: coerced.error }], details: failureDetails({}), isError: true };
420
528
  let params = coerced.params;
@@ -430,6 +538,8 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
430
538
  ? { ...params, outputContract: defaultOutputContract }
431
539
  : params;
432
540
  }
541
+ // Before classification and every ownership consumer (the gate, the overlap check, the run specs).
542
+ params = inheritAgentWriteSets(params);
433
543
  if (policy?.requireBrief) {
434
544
  const briefError = validateDelegationBrief(params);
435
545
  if (briefError) return { content: [{ type: "text", text: briefError }], details: failureDetails({}), isError: true };
@@ -462,16 +572,19 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
462
572
  const reason = effectiveConcurrency > 1
463
573
  ? "would overlap a material mutation"
464
574
  : "is ordered before a material mutation";
465
- const message = `delegate: fresh verification must run after every material mutation; ${names} ${reason}. Serialize the batch with every writer first and every declared verifier last, or start the verifier in a later call once every writer has SETTLED (its completion follow-up, or intercom { action:"wait", to:"<run-id>" }) — a later call while a writer is still running is rejected the same way.`;
575
+ const message = `delegate: fresh verification must run after every material mutation; ${names} ${reason}. Serialize the batch with every writer first and every declared verifier last, or start the verifier in a later call once every writer has SETTLED (interactive: end your turn; its completion follow-up wakes you) — a later call while a writer is still running is rejected the same way.`;
466
576
  return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
467
577
  }
468
578
  }
469
579
  if (effectiveConcurrency > 1) {
470
580
  if (policy?.requireDisjointWrites) {
471
581
  if (writing.length > 1) {
472
- const missing = writing.filter(({ task }) => !task.writeSet?.some((path) => path.trim())).map(({ index, task }) => `tasks[${index}] ("${task.agent}")`);
473
- if (missing.length > 0) {
474
- const message = `delegate: this persona requires disjoint ownership for parallel writers; missing non-empty writeSet on ${missing.join(", ")}. Declare repository-relative paths each of those legs alone may edit, restrict tools to read/grep/find/ls (or pick scout), or serialize with concurrency: 1.`;
582
+ const unowned = writing.filter(({ task }) => !task.writeSet?.some((path) => path.trim()));
583
+ if (unowned.length > 0) {
584
+ const missing = unowned.map(({ index, task }) => `tasks[${index}] ("${task.agent}")`);
585
+ // One remedy per leg, naming the parameters: the caller should never have to guess a fix.
586
+ const remedies = unowned.map(({ index, task }) => `tasks[${index}] ("${task.agent}"): add writeSet: ["<files or directories only this leg edits>"], or make it a reader with tools: ["read","grep","find","ls"] (or pick scout).`);
587
+ const message = `delegate: this persona requires disjoint ownership for parallel writers; missing non-empty writeSet on ${missing.join(", ")}. ${remedies.join(" ")} Or serialize the batch with concurrency: 1.`;
475
588
  return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
476
589
  }
477
590
  }
@@ -480,7 +593,7 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
480
593
  if (writeSetError) return { content: [{ type: "text", text: writeSetError }], details: failureDetails({}), isError: true };
481
594
  }
482
595
  }
483
- // The gate above sees ONE call. Interactive delegate is background by default, so the
596
+ // The gate above sees ONE call. Interactive delegate is ALWAYS background, so the
484
597
  // remedy it prescribes — run the verifier in a later call — lands WHILE the mutation is
485
598
  // still running unless the same rule holds across calls; the policy would otherwise be
486
599
  // defeated by following its own instructions. Guidance is never the enforcement here
@@ -498,8 +611,10 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
498
611
  const omittedMutations = liveMutations.length - visibleMutations.length;
499
612
  const inFlight = `${visibleMutations.map((run) => `${run.id} (${sanitizeLabel(run.agent)})`).join(", ")}${omittedMutations > 0 ? `, … +${omittedMutations} more` : ""}`;
500
613
  const message =
501
- `delegate: fresh verification must run after every material mutation; ${verifierList} cannot start while ${inFlight} ${liveMutations.length === 1 ? "is" : "are"} still mutating. ` +
502
- `Wait for the completion follow-up (or intercom { action:"wait", to:"${liveMutations[0]?.id}" }), then start the verifier against the resulting state.`;
614
+ `delegate: fresh verification must run after every material mutation; ${verifierList} cannot start while ${inFlight} ${liveMutations.length === 1 ? "is" : "are"} still running and may mutate the workspace. ` +
615
+ (inline
616
+ ? "The writer's own call returns its result; start the verifier in a later call against the resulting state."
617
+ : "End your turn; the writer's completion follow-up wakes you, then start the verifier against the resulting state.");
503
618
  return { content: [{ type: "text", text: message }], details: failureDetails({}), isError: true };
504
619
  }
505
620
  }
@@ -525,36 +640,13 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
525
640
  // identical delegation that already failed twice does not spawn again.
526
641
  const requested =
527
642
  params.tasks && params.tasks.length > 0
528
- ? params.tasks.map((t) => ({
529
- agent: t.agent,
530
- ...(t.model ? { model: t.model } : {}),
531
- task: t.task,
532
- ...(t.role ? { role: t.role } : {}),
533
- ...(t.tools !== undefined ? { tools: t.tools } : {}),
534
- ...(t.isolation ? { isolation: t.isolation } : {}),
535
- }))
643
+ ? params.tasks.map(keyInputOf)
536
644
  : params.agent && params.task
537
- ? [
538
- {
539
- agent: params.agent,
540
- ...(params.model ? { model: params.model } : {}),
541
- task: params.task,
542
- ...(params.role ? { role: params.role } : {}),
543
- ...(params.tools !== undefined ? { tools: params.tools } : {}),
544
- ...(params.isolation ? { isolation: params.isolation } : {}),
545
- },
546
- ]
645
+ ? [keyInputOf({ ...params, agent: params.agent, task: params.task })]
547
646
  : [];
548
647
  const veto = ledger.vet(requested);
549
648
  if (veto) return { content: [{ type: "text", text: veto }], details: failureDetails({}), isError: true };
550
- // Background by default in interactive sessions: the supervisor stays free and results
551
- // return as follow-ups (the idle-gated push path). Headless (`pi -p`) defaults to sync —
552
- // the single turn must carry the result, and nothing drains a follow-up after the
553
- // process exits. An explicit `async` always wins; `sync: true` opts one call out.
554
- const wantsAsync = wantsAsyncRun(params, ctx.hasUI === true);
555
- // Async (single OR parallel): run in the background so YOU stay free to keep
556
- // working / answer the user — results arrive later as follow-ups; /peek to watch.
557
- if (wantsAsync && params.tasks && params.tasks.length > 0) {
649
+ if (params.tasks && params.tasks.length > 0) {
558
650
  const tasks = params.tasks.slice(0, d.RUN_LIMITS.maxChildren);
559
651
  const dropped = params.tasks.length - tasks.length;
560
652
  const effectiveConcurrency = normalizeDelegateConcurrency(params.concurrency, d.RUN_LIMITS.maxConcurrency);
@@ -563,213 +655,151 @@ export function registerDelegateTool(pi: ExtensionAPI, d: DelegateToolDeps): voi
563
655
  const batchSlots = effectiveConcurrency < tasks.length ? new Semaphore(effectiveConcurrency) : undefined;
564
656
  const nameOffset = asyncNameSequence;
565
657
  asyncNameSequence += tasks.length;
658
+ // One id per call: the name offset only ever grows, so no two calls share it.
659
+ const batch = `batch-${nameOffset.toString(36)}`;
660
+ const fanOut = { batch, slots: batchSlots, groupId: !inline && tasks.length > 1 ? openBatchRow(batch, tasks.length) : undefined };
566
661
  const runs = tasks.map((t, i): DelegateLaunchSnapshot => {
567
- // Routed through specOf() (not a hand-rolled field list) so this, the interactive
568
- // DEFAULT delegate path, never drifts from the sync path's mapping — NP2's per-leg
569
- // `timeoutMs` (and any future knob) lands here for free instead of needing a second copy.
662
+ // Routed through specOf() (not a hand-rolled field list): a per-leg knob such as `timeoutMs`
663
+ // lands here for free instead of needing a second copy.
570
664
  const spec = specOf(t, nameOffset + i);
571
665
  const label = nameFor(t, nameOffset + i);
572
- const id = launchAsyncRun(t.agent, t.task, spec, label, batchSlots);
666
+ const id = launchAsyncRun(t.agent, t.task, spec, label, inline, fanOut);
573
667
  const model = shortModel(spec.model);
574
668
  return { id, label, agent: t.agent, ...(model ? { model } : {}) };
575
669
  });
576
670
  const ids = runs.map((run) => run.id);
577
671
  const droppedNote = dropped > 0 ? ` ${dropped} task(s) beyond the max-children limit (${d.RUN_LIMITS.maxChildren}) were dropped.` : "";
672
+ if (inline) return settleInline(ids, signal, onUpdate, droppedNote);
578
673
  return {
579
674
  content: [
580
675
  {
581
676
  type: "text",
582
- text: `Launched ${ids.length} async runs in the background (${ids.join(", ")}) — keep working; each notifies on completion. /peek to watch.${droppedNote}`,
677
+ text: `Launched ${ids.length} background leg${ids.length === 1 ? "" : "s"} as one batch: ${runs.map((run) => `${run.id} (${runDisplayName(run)})`).join(", ")}; you will be woken ONCE when the batch settles (${END_TURN_NOTE}).${droppedNote}`,
583
678
  },
584
679
  ],
585
680
  details: { runIds: ids, runs, ...(dropped > 0 ? { dropped } : {}) },
586
681
  isError: false,
587
682
  };
588
683
  }
589
- if (wantsAsync && params.agent && params.task) {
684
+ if (params.agent && params.task) {
590
685
  const agent = params.agent;
591
686
  const task = params.task;
592
687
  const nameIndex = asyncNameSequence++;
593
688
  // Use the canonical mapper here too: explicit `none`/`false` and future fields must
594
- // survive exactly as they do in fan-out and sync mode.
689
+ // survive exactly as they do in a fan-out.
595
690
  const single = { ...params, agent, task };
596
691
  const runSpec = specOf(single, nameIndex);
597
692
  const label = nameFor(single, nameIndex);
598
- const id = launchAsyncRun(agent, task, runSpec, label);
693
+ const id = launchAsyncRun(agent, task, runSpec, label, inline);
694
+ if (inline) return settleInline([id], signal, onUpdate);
599
695
  const model = shortModel(runSpec.model);
600
696
  return {
601
697
  content: [
602
698
  {
603
699
  type: "text",
604
- text: `Launched async run ${id} (${agent}) — runs in the background; you'll be notified on completion. /peek ${id} to watch.`,
700
+ text: `Launched async run ${id} (${agent}) — runs in the background and wakes you on completion (${END_TURN_NOTE}).`,
605
701
  },
606
702
  ],
607
703
  details: { runId: id, runs: [{ id, label, agent, ...(model ? { model } : {}) }] },
608
704
  isError: false,
609
705
  };
610
706
  }
611
- const delRoot = `delegate:${_toolCallId}`;
612
- d.agentTree.add({ id: delRoot, label: "delegate", status: "running", kind: "delegate" });
613
- try {
614
- const delegateLimits = { maxConcurrency: d.RUN_LIMITS.maxConcurrency, maxChildren: d.RUN_LIMITS.maxChildren };
615
- const outcome = await runDelegate(
616
- params,
617
- d.buildEngine(signal),
618
- delegateLimits,
619
- (views) => {
620
- views.forEach((v, i) => {
621
- const id = `${delRoot}/${i}`;
622
- if (!v.running) {
623
- d.stopRegistry.delete(id);
624
- d.stopRequested.delete(id);
625
- d.steerRegistry.delete(id);
626
- }
627
- const status = agentNodeStatusForDelegate(v);
628
- const spec = requested[i];
629
- const node: AddNodeInput = {
630
- id,
631
- label: v.label,
632
- parentId: delRoot,
633
- status,
634
- kind: "subagent",
635
- ...(spec?.agent ? { agent: spec.agent } : {}),
636
- ...(spec?.model ? { model: spec.model } : {}),
637
- };
638
- node.detail = v.running ? v.activity : formatUsage(v.usage);
639
- if (v.output) node.output = v.output;
640
- d.agentTree.add(node);
641
- });
642
- const done = views.filter((v) => !v.running).length;
643
- onUpdate?.({ content: [{ type: "text", text: `delegate: ${done}/${views.length} done` }], details: { views } });
644
- },
645
- (i, abort) => d.stopRegistry.set(`${delRoot}/${i}`, abort),
646
- (i, steer) => d.steerRegistry.set(`${delRoot}/${i}`, steer),
647
- // The same run signal the engine was built with: a leg whose engine REJECTS under a
648
- // whole-run stop must file as "abort", not as an agent failure the user never caused.
649
- signal,
650
- (i, event) => d.publishAgentTool(`${delRoot}/${i}`, event),
651
- );
652
- // Feed the anti-loop ledger (results align with the requested tasks by index).
653
- outcome.results.forEach((r, i) => {
654
- const t = requested[i];
655
- if (t && shouldRecordDelegationOutcome(r)) ledger.record(t, r.ok);
656
- });
657
- d.agentTree.update(delRoot, { status: signal?.aborted ? "stopped" : outcome.ok ? "done" : "failed" });
658
- const usage = sumUsage(outcome.results.map((r) => r.usage));
659
- d.childUsage.account(usage);
660
- d.publishPersonaCost();
661
- return {
662
- // Sub-agent text is untrusted even as a tool result (guardrails §: fence
663
- // before it reaches the supervisor) — the async path already fences via
664
- // buildCompletionReport; the sync path must match.
665
- content: [{ type: "text", text: `${fenceUntrusted(outcome.text)}${d.drainBusBlock()}` }],
666
- details: outcome.ok ? { views: outcome.views } : failureDetails({ views: outcome.views }),
667
- isError: !outcome.ok,
668
- ...toolUsageField(usage),
669
- };
670
- } catch (error) {
671
- d.agentTree.update(delRoot, { status: signal?.aborted ? "stopped" : "failed" });
672
- throw error;
673
- } finally {
674
- d.clearStops(delRoot);
675
- d.clearSteers(delRoot);
676
- d.agentTree.remove(delRoot);
677
- }
707
+ return {
708
+ content: [{ type: "text", text: "delegate: provide either { agent, task } or { tasks: [{ agent, task }, ...] }" }],
709
+ details: failureDetails({}),
710
+ isError: true,
711
+ };
678
712
  },
679
713
 
680
714
  renderCall(args, theme) {
681
715
  const title = theme.fg("toolTitle", theme.bold("delegate "));
682
716
  const coerced = coerceDelegateParams(args);
683
717
  const view = coerced.ok ? coerced.params : undefined;
684
- const mode = wantsAsyncRun(view ?? args, true) ? theme.fg("warning", " async") : theme.fg("dim", " sync");
685
718
  if (view?.tasks && view.tasks.length > 0) {
686
719
  // Names live in the tree / final card — keep the call line itself minimal.
687
- return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})`)}${mode}`, 0, 0);
720
+ return new Text(`${title}${theme.fg("accent", `parallel (${view.tasks.length})`)}`, 0, 0);
688
721
  }
689
722
  if (typeof args.tasks === "string" && args.tasks.trim()) {
690
- return new Text(`${title}${theme.fg("accent", "parallel")}${mode}`, 0, 0);
723
+ return new Text(`${title}${theme.fg("accent", "parallel")}`, 0, 0);
691
724
  }
692
- const identity = [view?.name ?? args.name, view?.agent ?? args.agent ?? "?"].filter(Boolean).join(" · ");
725
+ const aliasValue = (view?.name?.trim() ? view.name : undefined) ?? view?.agent ?? (typeof args.name === "string" ? args.name : undefined) ?? (typeof args.agent === "string" ? args.agent : "?");
726
+ const alias = compactInlineText(aliasValue || "?", { maxChars: 80 }) || "?";
693
727
  const modelValue = view?.model ?? args.model;
694
- const model = typeof modelValue === "string" && modelValue.trim() ? theme.fg("dim", ` · ${compactInlineText(shortModel(modelValue), { maxChars: 48 })}`) : "";
695
- return new Text(`${title}${theme.fg("accent", compactInlineText(identity, { maxChars: 96 }) || "?")}${model}${mode}`, 0, 0);
728
+ const agent = compactInlineText(view?.agent ?? (typeof args.agent === "string" ? args.agent : ""), { maxChars: 40 });
729
+ const model = typeof modelValue === "string" && modelValue.trim() ? compactInlineText(modelValue, { maxChars: 80 }) : "";
730
+ const titleLine = `${title}${theme.fg("accent", theme.bold(compactVisibleLine(alias, MAX_COLLAPSED_CARD_COLUMNS - visibleWidth("delegate "))))}`;
731
+ const metadata = [agent, model].filter(Boolean).join(" · ");
732
+ const metadataLine = metadata ? theme.fg("dim", compactVisibleLine(` ${metadata}`, MAX_COLLAPSED_CARD_COLUMNS)) : "";
733
+ return new Text([titleLine, metadataLine].filter(Boolean).join("\n"), 0, 0);
696
734
  },
697
735
 
698
- renderResult(result, { expanded }, theme, context) {
699
- // Safe: the delegate `execute` above always stores `{ views: DelegateView[] }` (sync,
700
- // single/parallel) or `{ runId | runIds }` (async) in `details`; the double cast just narrows
736
+ renderResult(result, { expanded, isPartial: resultIsPartial }, theme, context) {
737
+ // Safe: the delegate `execute` above stores `{ runId | runIds, runs, dropped }` (interactive launch)
738
+ // or nothing (a headless call's report is its text) in `details`; the double cast just narrows
701
739
  // Pi's opaque `details` type to that known shape for rendering.
702
- const details = result.details as unknown as { views?: DelegateView[]; runId?: string; runIds?: string[]; runs?: unknown; dropped?: number } | undefined;
703
- const views = details?.views ?? [];
704
- if (views.length === 0) {
705
- const first = result.content[0];
706
- const fallback = details?.runIds?.length
707
- ? `async runs ${details.runIds.join(", ")}`
708
- : details?.runId
709
- ? `async run ${details.runId}`
710
- : "(no output)";
711
- const text = sanitizeTerminalText(first?.type === "text" ? first.text : fallback);
712
- const allowTracker = context === undefined || context.isPartial === true;
713
- const rows = details ? launchRows(details, d.tracker, allowTracker) : [];
714
- if (expanded) return new Text(expandedLaunchDetails(text, rows), 0, 0);
715
- if (details?.runId || details?.runIds?.length) {
716
- if (rows.length > 0 && !result.isError) {
717
- return new Text(theme.fg("accent", collapsedLaunchSummary(rows, details.dropped)), 0, 0);
740
+ const details = result.details as unknown as { runId?: string; runIds?: string[]; runs?: unknown; dropped?: number } | undefined;
741
+ // Native Pi supplies only content/details in result; the host owns the error flag.
742
+ const isError = context?.isError ?? result.isError;
743
+ const first = result.content[0];
744
+ const fallback = details?.runIds?.length
745
+ ? `async runs ${details.runIds.join(", ")}`
746
+ : details?.runId
747
+ ? `async run ${details.runId}`
748
+ : "(no output)";
749
+ const text = sanitizeTerminalText(first?.type === "text" ? first.text : fallback);
750
+ const allowTracker = context === undefined || context.isPartial === true;
751
+ const rows = details ? launchRows(details, d.tracker, allowTracker) : [];
752
+ if (expanded) {
753
+ if (details?.runId || details?.runIds?.length) return new Text(expandedLaunchDetails(text, rows), 0, 0);
754
+ // A headless report may contain failed legs and have empty details too. Only its
755
+ // runtime settlement envelope distinguishes that report from a host diagnostic.
756
+ const report = /^\[pi-persona\] \d+ async runs? settled — \d+ done, \d+ failed(?:, \d+ stopped)?(?:\n|$)/.test(text);
757
+ if (isError && !report) return new Text(theme.fg("toolOutput", text), 0, 0);
758
+ return createReportBody(text, theme, {}, context?.lastComponent);
759
+ }
760
+ if (details?.runId || details?.runIds?.length) {
761
+ if (rows.length > 0 && !isError) {
762
+ // A one-admitted-leg fan-out is still a fan-out when maxChildren dropped
763
+ // declared tasks; keep its truncation count in the collapsed receipt.
764
+ const hasDroppedTasks = typeof details.dropped === "number"
765
+ && Number.isFinite(details.dropped)
766
+ && details.dropped > 0;
767
+ if (rows.length === 1 && !hasDroppedTasks && context
768
+ && matchesCompleteSingleCall(context.args, context.argsComplete, context.isPartial || resultIsPartial, rows[0]!)) {
769
+ return new Text(theme.fg("accent", theme.bold("launched")), 0, 0);
718
770
  }
719
- if (result.isError) {
720
- const preview = compactVisibleText(text, { maxLines: 4, maxLineChars: 100 });
721
- return new Text(`${theme.fg("error", theme.bold("failed"))}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
771
+ if (rows.length === 1 && !hasDroppedTasks) {
772
+ const lines = singleLaunchReceipt(rows[0]!);
773
+ return new Text([
774
+ theme.fg("accent", theme.bold(lines[0] ?? "launched")),
775
+ ...lines.slice(1).map((line) => theme.fg("dim", line)),
776
+ ].join("\n"), 0, 0);
722
777
  }
723
- const ids = details.runIds?.length ? details.runIds.slice(0, 3) : [details.runId as string];
724
- const omitted = (details.runIds?.length ?? 0) - ids.length;
725
- const dropped = details.dropped ? ` · ${details.dropped} task${details.dropped === 1 ? "" : "s"} dropped (max children)` : "";
726
- return new Text(`${theme.fg("toolTitle", theme.bold("delegate "))}${theme.fg("accent", `launched ${ids.join(", ")}${omitted > 0 ? `, +${omitted} more` : ""}`)}${theme.fg("dim", dropped)}`, 0, 0);
778
+ const lines = collapsedLaunchRows(rows, details.dropped);
779
+ return new Text(lines.map((line, index) => {
780
+ const prefix = index === 0
781
+ ? theme.fg("toolTitle", theme.bold(line.prefix))
782
+ : theme.fg("dim", line.prefix);
783
+ const label = line.label ? theme.fg("accent", theme.bold(line.label)) : "";
784
+ const metadata = line.metadata ? theme.fg("dim", ` · ${line.metadata}`) : "";
785
+ const suffix = line.suffix ? theme.fg("dim", line.suffix) : "";
786
+ return `${prefix}${label}${metadata}${suffix}`;
787
+ }).join("\n"), 0, 0);
727
788
  }
728
- if (result.isError) {
789
+ if (isError) {
729
790
  const preview = compactVisibleText(text, { maxLines: 4, maxLineChars: 100 });
730
- return new Text(`${theme.fg("error", theme.bold("delegate failed"))}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
731
- }
732
- return new Text(theme.fg("toolOutput", compactVisibleText(text, { maxLines: 4, maxLineChars: 100 }).text), 0, 0);
733
- }
734
- const title = theme.fg("toolTitle", theme.bold("delegate "));
735
- const running = views.filter((v) => v.running).length;
736
- // While running, render nothing — the live per-agent view is the tree widget
737
- // (and the f9 overlay). A sticky card here would just duplicate it. The full
738
- // per-leg cards below appear once the run completes.
739
- if (running > 0) return new Container();
740
- const okCount = views.filter((v) => v.ok).length;
741
- const container = new Container();
742
- container.addChild(new Text(`${title}${theme.fg("accent", `${okCount}/${views.length} ok`)}`, 0, 0));
743
- // A failure is actionable; never bury it below a page of successful legs. Collapsed cards
744
- // show at most three semantic one-line previews. Expansion remains the lossless inspection
745
- // surface, while F9 keeps the live navigable tree.
746
- const ordered = expanded ? views : [...views].sort((a, b) => Number(a.ok) - Number(b.ok));
747
- const visible = expanded ? ordered : ordered.slice(0, 3);
748
- for (const v of visible) {
749
- const icon = v.ok ? theme.fg("success", "✓") : theme.fg("error", "✗");
750
- const usageStr = formatUsage(v.usage);
751
- const usage = usageStr ? theme.fg("dim", ` ${usageStr}`) : "";
752
- const body = sanitizeTerminalText(v.output || "(no output)");
753
- if (expanded) {
754
- container.addChild(new Spacer(1));
755
- container.addChild(new Text(`${icon} ${theme.fg("accent", compactInlineText(v.label, { maxChars: 96 }) || "agent")}${usage}`, 0, 0));
756
- container.addChild(new Text(theme.fg("toolOutput", body), 0, 0));
757
- continue;
791
+ return new Text(`${theme.fg("error", theme.bold("failed"))}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
758
792
  }
759
- // Keep the collapsed semantic row below 100 columns even on a very wide terminal.
760
- // Usage remains one keystroke away in expanded mode; the collapsed card prioritizes
761
- // identity + outcome instead of wrapping one result into several pseudo-rows.
762
- const preview = compactVisibleText(body, { maxLines: 1, maxLineChars: 60 });
763
- container.addChild(
764
- new Text(`${icon} ${theme.fg("accent", compactInlineText(v.label, { maxChars: 28 }) || "agent")} · ${theme.fg("toolOutput", preview.text)}`, 0, 0),
765
- );
793
+ const ids = details.runIds?.length ? details.runIds.slice(0, 3) : [details.runId as string];
794
+ const omitted = (details.runIds?.length ?? 0) - ids.length;
795
+ const dropped = details.dropped ? ` · ${details.dropped} task${details.dropped === 1 ? "" : "s"} dropped (max children)` : "";
796
+ return new Text(`${theme.fg("toolTitle", theme.bold("delegate "))}${theme.fg("accent", `launched ${ids.join(", ")}${omitted > 0 ? `, +${omitted} more` : ""}`)}${theme.fg("dim", dropped)}`, 0, 0);
766
797
  }
767
- if (!expanded) {
768
- const omitted = views.length - visible.length;
769
- const prefix = omitted > 0 ? `… +${omitted} more result${omitted === 1 ? "" : "s"} · ` : "";
770
- container.addChild(new Text(theme.fg("dim", `${prefix}${expandDetailHint()}`), 0, 0));
798
+ if (isError) {
799
+ const preview = compactVisibleText(text, { maxLines: 4, maxLineChars: 100 });
800
+ return new Text(`${theme.fg("error", theme.bold("delegate failed"))}\n${theme.fg("toolOutput", preview.text)}${preview.truncated ? `\n${theme.fg("dim", expandDetailHint())}` : ""}`, 0, 0);
771
801
  }
772
- return container;
802
+ return new Text(theme.fg("toolOutput", compactVisibleText(text, { maxLines: 4, maxLineChars: 100 }).text), 0, 0);
773
803
  },
774
804
  });
775
805
  }