@arhen/pi-core-subagent 1.3.64 → 1.3.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # @arhen/pi-core-subagent
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/%40arhen%2Fpi-core-subagent?color=cb3837&logo=npm)](https://www.npmjs.com/package/@arhen/pi-core-subagent)
4
- [![CI](https://img.shields.io/github/actions/workflow/status/arhen/pi-core-subagent/ci.yml?branch=main&logo=github&label=CI)](https://github.com/arhen/pi-core-subagent/actions/workflows/ci.yml)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/arhen/pi-extensions/pi-core-subagent.yml?branch=main&logo=github&label=CI)](https://github.com/arhen/pi-extensions/actions/workflows/pi-core-subagent.yml)
5
5
  [![license](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE)
6
6
  [![pi extension](https://img.shields.io/badge/pi-extension-7c3aed)](https://github.com/earendil-works/pi)
7
7
 
@@ -82,7 +82,7 @@ The dotted arrows are the whole point: a child may burn 200k tokens reading file
82
82
 
83
83
  ## Usage — the leader invents the agents
84
84
 
85
- Define agents inline per call, or reference a named agent file (see [Agent files](#agent-files)). Model resolution: explicit `model` → agent-file `model` (validated against the pi model registry) → the parent's current model → settings default.
85
+ Define agents inline per call, or reference a named agent file (see [Agent files](#agent-files)). Model resolution: explicit `model` → agent-file `model` (validated against the pi model registry) → the parent's current model → settings default. Any model that is not the session model is preflight-probed; if the provider rejects it, the task falls back to the session model and the result carries a `Model:` note.
86
86
 
87
87
  ```json
88
88
  {
@@ -133,7 +133,7 @@ You are a strict API reviewer. Check auth, rate limiting, and error handling. Ci
133
133
  1. `.agents/agents/` then `.claude/agents/` then `.pi/agents/` in each directory from the task `cwd` up to the filesystem root (nearest ancestor wins).
134
134
  2. Home: `~/.agents/agents/` (single source) → `~/.claude/agents/` → `~/.pi/agents/`.
135
135
 
136
- Within a directory the file with the highest description-overlap score wins (≥2 shared meaningful tokens). A file `model` is validated against the pi model registry (unknown model fails the task with a catalog message). Files without a `description` frontmatter never match.
136
+ Within a directory the file with the highest description-overlap score wins (≥2 shared meaningful tokens and ≥40% coverage of the shorter token set). A file `model` is validated against the pi model registry (unknown model fails the task with a catalog message). File bodies are capped at 64,000 characters. Files without a `description` frontmatter never match.
137
137
 
138
138
  ## Worktree isolation (write agents)
139
139
 
@@ -184,7 +184,6 @@ The call line renders the graph in §2 notation as the model types it:
184
184
 
185
185
  ```
186
186
  subagent graph 3
187
- 6 at a time
188
187
  wave1[api ∥ db] → gate → wave2[doc]
189
188
  api api-mapper Map every route in src/api/
190
189
  db db-mapper Map the schema in src/db/
@@ -292,7 +291,7 @@ Background (default) + intercom — the run returns a runId immediately; you sta
292
291
  |---|---|
293
292
  | `subagent_models` | list the models a subagent task may name, each with the exact `model` value, thinking levels the runtime honors, context window, and catalog price; scoped to the session's enabled set when scoping is configured, else the full available catalogue (the output says which) |
294
293
  | `subagent` | single / `tasks` (parallel or graph via `needs`) / `chain` (`{previous}`); every run is background — returns a runId, completion notifies you; `autoAwait:true` parks the call until the run finishes and returns the final result inline; children always carry talk tools (ask/notify/mailbox); `notifyPerTask` (default true) wakes you as each task completes |
295
- | `subagent_status` | live per-task snapshot (non-blocking), including each child's session file path |
294
+ | `subagent_status` | live per-task snapshot (non-blocking), including each child's session file path; call it once right after spawning — a child that died on spawn is invisible until far later otherwise |
296
295
  | `subagent_result` | full output of a run or one task |
297
296
  | `await_subagent` | block until a run finishes (optional `timeoutMs`) |
298
297
  | `reply_subagent` | answer a child's `ask_parent` question |
@@ -302,20 +301,20 @@ Background (default) + intercom — the run returns a runId immediately; you sta
302
301
 
303
302
  ### Per-task fields
304
303
 
305
- `agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`; omitted → the leader's current session model, a matched agent file's frontmatter wins), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id`, `needs` (dependency edges — see [Graph mode](#graph-mode--needs)). Top-level only: `autoAwait`, `notifyPerTask`, `concurrency`.
304
+ `agent` (name you invent — required), `task` (required), `prompt` (system prompt, optional — minimal default used), `write` (toolset, default read-only), plus optional `model` (`provider/model-id`; omitted → the leader's current session model, a matched agent file's frontmatter wins), `thinking` (validated enum: `off|minimal|low|medium|high|xhigh|max`), `tools` (explicit allowlist), `cwd`, `maxRuntimeMs`, `id`, `needs` (dependency edges — see [Graph mode](#graph-mode--needs)). Top-level only: `autoAwait`, `notifyPerTask`, `concurrency` (default 3, max 8). A call carries at most 16 tasks.
306
305
 
307
306
  ### Child talk tools (always on)
308
307
 
309
308
  | Tool | Meaning |
310
309
  |---|---|
311
310
  | `ask_parent` | blocking question to the leader; delivered mid-turn as a **steering** message labelled `[URGENT]` or `[not urgent]`, parent answers via `reply_subagent` |
312
- | `notify_parent` | one-way message to the leader |
311
+ | `notify_parent` | one-way message to the leader; identical updates coalesce, `final: true` declares the complete report |
313
312
  | `send_agent_message` | message to a sibling subagent's mailbox (`to` = its task id, or `"leader"`) |
314
313
  | `poll_agent_messages` | drain this subagent's mailbox |
315
314
 
316
315
  > **Intercom anti-deadlock:** children are told to never block indefinitely on intercom replies — an unanswered `ask_parent` times out after 10 minutes (the child is told to proceed with best judgment), and sibling polls are capped (~5 tries) with the same fallback. Gated siblings (later waves) may not be running yet — waiting on them is the top stall cause, so children are instructed not to.
317
316
 
318
- > **Ask urgency:** `ask_parent` takes `urgent` (default `false`). Both variants steer into the leader's current turn so the question is never deferred to the end of a long turn. `[URGENT]` tells the leader to answer before its next step; `[not urgent]` tells it that the child keeps waiting, so it may finish its current step first. Failures steer for the same reason; completions and aborts queue as follow-ups.
317
+ > **Ask urgency:** `ask_parent` takes `urgent` (default `false`). Both variants steer into the leader's current turn so the question is never deferred to the end of a long turn. `[URGENT]` tells the leader to answer before its next step; `[not urgent]` tells it that the child keeps waiting, so it may finish its current step first. Failures steer for the same reason. Completions, aborts, and informational updates are held in an extension outbox and delivered as follow-ups when the leader settles; a receipt confirms against the finalized leader transcript, so a report the leader already consumed (or an awaited result that already rendered the outcome) suppresses its redundant queued notice. See [docs/notification-policy.md](docs/notification-policy.md).
319
318
 
320
319
  ## Tool exposure and opt-in codemode
321
320
 
@@ -328,9 +327,12 @@ still applies Pi\'s global script-only policy; this extension does not override
328
327
  - `/subagents mode codemode` — explicitly select script-based routing (direct fallback when unavailable).
329
328
  - `/subagents mode direct` — return to legacy direct behavior.
330
329
 
331
- Choice is stored per session branch. New sessions/branches without a preference start direct;
332
- existing explicit choices remain intact. Published 1.3.63 defaulted to `auto`: this compatibility
333
- correction requires the corrected source/package, not a retroactive change to that npm release.
330
+ Choice is stored globally in `~/.pi/agent/subagents-config.json` (the same file as `auto-limit`,
331
+ written with a merge, never a clobber) and applies to every session — reload, resume, fork and tree
332
+ navigation included. Sessions that chose a mode before the preference moved into that file keep their
333
+ branch entry only while the config has no `mode`; the built-in default is still `direct`. Published
334
+ 1.3.63 defaulted to `auto`: this compatibility correction requires the corrected source/package, not
335
+ a retroactive change to that npm release.
334
336
 
335
337
  ## Commands
336
338
 
@@ -375,6 +377,7 @@ The extension has no multiplexer integration and does not want one: it exposes t
375
377
  ## Context budget
376
378
 
377
379
  - Parent tools: 9 schemas with short descriptions. **No catalog, no context hook** — nothing injected per request.
380
+ - The widget shows live work only: settled runs are pruned from it and stay reachable through `subagent_status` / `subagent_result`.
378
381
  - Background completion: 3-line notice. Full text only via `subagent_result`.
379
382
  - Children: isolated sessions; talk tools always injected; each child's prompt states its own task id and its siblings' so mailbox addressing works. Model resolution: explicit `provider/model-id` or bare id via the pi model registry → the parent's current model → settings default. Thinking levels validated against the resolved model's `thinkingLevelMap`; `subagent_models` lists the levels the runtime honors when you need a safe set.
380
383
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arhen/pi-core-subagent",
3
- "version": "1.3.64",
3
+ "version": "1.3.66",
4
4
  "type": "module",
5
5
  "description": "pi extension: fast in-process subagents with a dependency-graph scheduler (needs edges gate tasks and carry upstream output into dependent prompts), plus background runs, intercom and agent-to-agent mailbox. Leader defines agents inline.",
6
6
  "license": "MIT",
package/src/child.ts CHANGED
@@ -5,10 +5,17 @@ import type { MailboxMessage } from "./mailbox.ts";
5
5
 
6
6
  export const CHILD_TALK_TOOLS = ["ask_parent", "notify_parent", "send_agent_message", "poll_agent_messages"] as const;
7
7
 
8
+ type ParentNotifyHandler = (
9
+ taskId: string,
10
+ message: string,
11
+ level: "info" | "warning" | "error",
12
+ final?: boolean,
13
+ ) => boolean;
14
+
8
15
  export interface ChildHandlers {
9
16
  onAskParent(taskId: string, question: string, urgent: boolean): Promise<string>;
10
- onNotifyParent(taskId: string, message: string, level: "info" | "warning" | "error"): void;
11
- onSendMessage(taskId: string, to: string, text: string): boolean;
17
+ onNotifyParent: ParentNotifyHandler | ((...args: Parameters<ParentNotifyHandler>) => void);
18
+ onSendMessage(taskId: string, to: string, text: string, final?: boolean): boolean;
12
19
  onPollMailbox(taskId: string): MailboxMessage[];
13
20
  }
14
21
 
@@ -45,16 +52,37 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
45
52
  name: "notify_parent",
46
53
  label: "Notify Parent",
47
54
  description:
48
- "Send a non-blocking message to the parent agent (a finding, a risk, a heads-up). Your run continues immediately; the parent sees it on its next turn.",
55
+ "Send a non-blocking update to the parent. Identical updates send once per task attempt. Set final:true only for your complete final report, as your last tool call; repeat that exact report as your final answer. A received final report suppresses redundant success follow-ups, not new results or failures.",
49
56
  promptSnippet: "Send the parent a non-blocking update or finding.",
50
57
  parameters: Type.Object({
51
58
  message: Type.String({ description: "The message content for the parent" }),
52
59
  level: Type.Optional(StringEnum(["info", "warning", "error"] as const, { default: "info" })),
60
+ final: Type.Optional(
61
+ Type.Boolean({
62
+ description: "Complete final report; no further tool work. Repeat this exact message as your final answer.",
63
+ default: false,
64
+ }),
65
+ ),
53
66
  }),
54
67
  async execute(_toolCallId, params) {
55
- const { message, level } = params as { message: string; level?: "info" | "warning" | "error" };
56
- handlers.onNotifyParent(taskId, message, level ?? "info");
57
- return { content: [{ type: "text" as const, text: "Sent." }], details: {} };
68
+ const { message, level, final } = params as {
69
+ message: string;
70
+ level?: "info" | "warning" | "error";
71
+ final?: boolean;
72
+ };
73
+ const submitted = handlers.onNotifyParent(taskId, message, level ?? "info", final === true) !== false;
74
+ return {
75
+ content: [
76
+ {
77
+ type: "text" as const,
78
+ text: submitted
79
+ ? "Submitted to parent (or already submitted). Receipt is tracked separately."
80
+ : "Parent notification could not be submitted; retry or include it in your final answer.",
81
+ },
82
+ ],
83
+ isError: !submitted,
84
+ details: {},
85
+ };
58
86
  },
59
87
  },
60
88
  {
@@ -68,19 +96,42 @@ export function createChildTools(taskId: string, handlers: ChildHandlers): ToolD
68
96
  description: "Target task id of another subagent in this run (e.g. task_2), or 'leader' for the parent agent",
69
97
  }),
70
98
  message: Type.String({ description: "Short message content (keep under ~500 chars)" }),
99
+ final: Type.Optional(
100
+ Type.Boolean({
101
+ description: "For to:leader only: complete final report, same contract as notify_parent final:true.",
102
+ default: false,
103
+ }),
104
+ ),
71
105
  }),
72
106
  async execute(_toolCallId, params) {
73
- const { to, message } = params as { to: string; message: string };
74
- if (!handlers.onSendMessage(taskId, to, message)) {
107
+ const { to, message, final } = params as { to: string; message: string; final?: boolean };
108
+ if (!handlers.onSendMessage(taskId, to, message, final === true)) {
75
109
  return {
76
110
  content: [
77
- { type: "text" as const, text: `Unknown target '${to}'. Use a sibling task id in this run or 'leader'.` },
111
+ {
112
+ type: "text" as const,
113
+ text:
114
+ to === "leader"
115
+ ? "Parent notification could not be submitted; retry or include it in your final answer."
116
+ : `Unknown target '${to}'. Use a sibling task id in this run or 'leader'.`,
117
+ },
78
118
  ],
79
119
  isError: true,
80
120
  details: {},
81
121
  };
82
122
  }
83
- return { content: [{ type: "text" as const, text: "Sent." }], details: {} };
123
+ return {
124
+ content: [
125
+ {
126
+ type: "text" as const,
127
+ text:
128
+ to === "leader"
129
+ ? "Submitted to leader (or already submitted). Receipt is tracked separately."
130
+ : "Sent.",
131
+ },
132
+ ],
133
+ details: {},
134
+ };
84
135
  },
85
136
  },
86
137
  {
package/src/config.ts ADDED
@@ -0,0 +1,60 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ /**
7
+ * Shared user config for the extension: exposure mode plus the auto-limit ceiling. One file, so a
8
+ * write of one key must never drop the other — every writer merges through `writeSubagentConfig`.
9
+ */
10
+ export const SUBAGENT_CONFIG_FILENAME = "subagents-config.json";
11
+
12
+ /**
13
+ * Presentation preference for the subagent toolset, stored globally in `subagents-config.json`.
14
+ * - auto: native codemode presentation when the codemode tool is active, direct otherwise.
15
+ * - direct (default): legacy native declarations and active-tool script callability.
16
+ * - codemode: subagent tools are callable from codemode scripts but not declared or listed.
17
+ */
18
+ export type SubagentMode = "auto" | "direct" | "codemode";
19
+
20
+ export const DEFAULT_SUBAGENT_MODE: SubagentMode = "direct";
21
+
22
+ export function isSubagentMode(value: unknown): value is SubagentMode {
23
+ return value === "auto" || value === "direct" || value === "codemode";
24
+ }
25
+
26
+ /** Resolved per call, so a test or `PI_CODING_AGENT_DIR` override applies to the running process. */
27
+ export function subagentConfigPath(): string {
28
+ return join(getAgentDir(), SUBAGENT_CONFIG_FILENAME);
29
+ }
30
+
31
+ /**
32
+ * Tolerant read: a missing, unreadable or non-object file degrades to an empty object, so a config
33
+ * typo can neither break delegation nor hide the built-in defaults.
34
+ */
35
+ export function readSubagentConfig(path = subagentConfigPath()): Record<string, unknown> {
36
+ try {
37
+ const raw: unknown = JSON.parse(readFileSync(path, "utf8"));
38
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return {};
39
+ return raw as Record<string, unknown>;
40
+ } catch {
41
+ return {};
42
+ }
43
+ }
44
+
45
+ /** The stored exposure mode, or undefined when the file has no usable one. */
46
+ export function readStoredMode(path = subagentConfigPath()): SubagentMode | undefined {
47
+ const mode = readSubagentConfig(path).mode;
48
+ return isSubagentMode(mode) ? mode : undefined;
49
+ }
50
+
51
+ let writeQueue: Promise<unknown> = Promise.resolve();
52
+
53
+ /** Read-modify-write on a single queue, so concurrent writers of different keys cannot clobber each other. */
54
+ export function writeSubagentConfig(patch: Record<string, unknown>, path = subagentConfigPath()): Promise<void> {
55
+ const write = writeQueue.then(async () => {
56
+ await writeFile(path, `${JSON.stringify({ ...readSubagentConfig(path), ...patch }, null, 2)}\n`);
57
+ });
58
+ writeQueue = write.catch(() => {});
59
+ return write;
60
+ }
package/src/format.ts CHANGED
@@ -208,6 +208,9 @@ function worktreeLine(task: TaskSnapshot, siblings?: TaskSnapshot[]): string {
208
208
  }
209
209
 
210
210
  export function makeSummary(run: RunSnapshot): string {
211
+ return makeSummaryWithCoverage(run).text;
212
+ }
213
+ export function makeSummaryWithCoverage(run: RunSnapshot): { text: string; coveredTaskIds: Set<string> } {
211
214
  const succeeded = run.tasks.filter((t) => t.status === "completed").length;
212
215
  const failed = run.tasks.filter((t) => t.status === "failed").length;
213
216
  const aborted = run.tasks.filter((t) => t.status === "aborted").length;
@@ -217,6 +220,7 @@ export function makeSummary(run: RunSnapshot): string {
217
220
  ];
218
221
  const usage = formatUsage(run.aggregateUsage);
219
222
  if (usage) lines.push(`Usage: ${usage}`);
223
+ const taskEnds = new Map<string, number>();
220
224
  for (const task of run.tasks) {
221
225
  const edge = task.needs?.length ? ` (${task.id}, needs ${task.needs.join(", ")})` : ` (${task.id})`;
222
226
  const fileNote = task.agentFile ? ` [${task.agentFile}]` : "";
@@ -225,9 +229,14 @@ export function makeSummary(run: RunSnapshot): string {
225
229
  lines.push(
226
230
  `\n## ${task.agent}${edge}${fileNote} ${statusIcon(task.status)}${swap}${tools}${task.error ? `\nError: ${task.error}` : `\n${truncateText(task.finalText || "(no output)")}`}${worktreeLine(task, run.tasks)}`,
227
231
  );
232
+ taskEnds.set(task.id, lines.join("\n").length);
228
233
  }
229
234
 
230
- return truncateText(lines.join("\n"));
235
+ const fullText = lines.join("\n");
236
+ const text = truncateText(fullText);
237
+ let prefix = 0;
238
+ while (prefix < fullText.length && prefix < text.length && fullText[prefix] === text[prefix]) prefix++;
239
+ return { text, coveredTaskIds: new Set([...taskEnds].filter(([, end]) => end <= prefix).map(([id]) => id)) };
231
240
  }
232
241
  export function isStartupFailure(task: TaskSnapshot, kind: string): boolean {
233
242
  return kind === "failed" && !task.finalText?.trim();
@@ -244,7 +253,7 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
244
253
  const tools = task.toolsNote ? `\nTools: ${task.toolsNote}` : "";
245
254
  return [
246
255
  `Task ${task.agent} (${task.id}) ${kind} in run ${run.id}: ${detail}${wt}`,
247
- `Goal: ${goal}${src}${swap}${tools}`,
256
+ `Goal: ${goal}${src}${swap}${tools}${task.worktreeError ? `\nWorktree: ${task.worktreeError}` : ""}`,
248
257
  isStartupFailure(task, kind)
249
258
  ? "Never started — stop and diagnose before spawning anything else: a config-level error (model, plan, auth, agent file) fails identically on every respawn."
250
259
  : kind === "completed"
@@ -252,6 +261,9 @@ export function makeTaskNotice(run: RunSnapshot, task: TaskSnapshot, kind: strin
252
261
  : `Session file kept — resume_subagent({ runId: "${run.id}", taskId: "${task.id}", model?: ... }) revives it with full context. subagent_result for what it produced so far.`,
253
262
  ].join("\n");
254
263
  }
264
+ export function makeTaskArtifactNotice(run: RunSnapshot, task: TaskSnapshot): string {
265
+ return `Task ${task.agent} (${task.id}) completed in run ${run.id}; result already delivered.${worktreeLine(task, run.tasks)}\nUse subagent_result({ runId: "${run.id}", taskId: "${task.id}" }) for artifacts.`;
266
+ }
255
267
  export function makeAskNotice(
256
268
  run: RunSnapshot,
257
269
  extra: { taskId?: string; agent?: string; question?: string; urgent?: boolean },
@@ -263,12 +275,14 @@ export function makeAskNotice(
263
275
  : `[not urgent] Subagent ${who} asks: ${extra.question ?? ""}\nIt waits while you keep working — finish your current step first if you want, then answer with ${reply}.`;
264
276
  }
265
277
 
266
- export function makeNotice(run: RunSnapshot, kind: string): string {
278
+ export function makeNotice(run: RunSnapshot, kind: string, tasks = run.tasks): string {
267
279
  const lines = [
268
280
  `Background subagent run ${run.id} ${kind}: ${run.tasks.filter((t) => t.status === "completed").length}/${run.tasks.length} succeeded.`,
269
281
  ];
270
- for (const task of run.tasks) {
271
- lines.push(`- ${task.agent}: ${task.status}${task.error ? ` — ${truncateText(task.error, 200)}` : ""}`);
282
+ for (const task of tasks) {
283
+ lines.push(
284
+ `- ${task.agent}: ${task.status}${task.error ? ` — ${truncateText(task.error, 200)}` : ""}${worktreeLine(task, run.tasks)}`,
285
+ );
272
286
  }
273
287
  lines.push(`Use subagent_result({ runId: "${run.id}" }) for full output.`);
274
288
  return lines.join("\n");
package/src/index.ts CHANGED
@@ -4,7 +4,7 @@ import type { TSchema } from "typebox";
4
4
  import {
5
5
  compactLines,
6
6
  formatUsage,
7
- makeSummary,
7
+ makeSummaryWithCoverage,
8
8
  renderModelCatalog,
9
9
  statusIcon,
10
10
  taskLine,
@@ -29,6 +29,24 @@ import {
29
29
  import { type ModelCatalog, type RunDetails, type RunSnapshot, TERMINAL } from "./types.ts";
30
30
  import { cleanupMerged, ownerAlive, reapDeadWorktrees, repoRoot, sweepStale } from "./worktree.ts";
31
31
 
32
+ function unreportedIntercom(run: RunSnapshot, messages: ParkedMsg[], covered: ReadonlySet<string>): ParkedMsg[] {
33
+ return messages.filter((message) => {
34
+ const task = run.tasks.find((task) => task.id === message.taskId);
35
+ return (
36
+ !covered.has(message.taskId) ||
37
+ task?.status !== "completed" ||
38
+ !(
39
+ message.kind === "done" ||
40
+ (message.kind === "notify" && message.final && task.finalText?.trim() === message.text.trim())
41
+ )
42
+ );
43
+ });
44
+ }
45
+
46
+ function intercomLine(message: ParkedMsg): string {
47
+ return `- [${message.kind === "notify" && message.final ? "final" : message.kind}] ${message.agent} (${message.taskId}): ${truncateText(message.text)}`;
48
+ }
49
+
32
50
  export default function (pi: ExtensionAPI) {
33
51
  const manager = new SubagentManager(pi);
34
52
  const definitions: AnyToolDefinition[] = [];
@@ -74,7 +92,7 @@ export default function (pi: ExtensionAPI) {
74
92
  };
75
93
  pi.registerCommand("subagents", {
76
94
  description:
77
- "List subagent runs. `/subagents peek` opens the browsable pane; `/subagents mode [auto|direct|codemode]` reports or switches the tool exposure profile; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
95
+ "List subagent runs. `/subagents peek` opens the browsable pane; `/subagents mode [auto|direct|codemode]` reports or switches the globally stored tool exposure profile; `/subagents auto-limit on|off` toggles the 1 h default runtime ceiling (default off = 6 h).",
78
96
  handler: async (args, ctx) => {
79
97
  const arg = String(args ?? "")
80
98
  .trim()
@@ -127,10 +145,24 @@ export default function (pi: ExtensionAPI) {
127
145
  pi.registerShortcut("ctrl+shift+a", { description: "Peek at running subagents", handler: openPeek });
128
146
 
129
147
  pi.on("agent_start", (_event, ctx) => {
148
+ manager.noteAgentStart();
130
149
  if (!manager.turnActivity && !manager.hasActiveRun()) manager.clearWidget(ctx);
131
150
  manager.turnActivity = false;
132
151
  });
133
152
 
153
+ pi.on("context", (_event, ctx) => {
154
+ manager.confirmLeaderMessages(ctx);
155
+ });
156
+ pi.on("agent_end", (_event, ctx) => {
157
+ manager.confirmLeaderMessages(ctx);
158
+ manager.scheduleNotificationFlush(ctx);
159
+ });
160
+ // Settlement is the last automatic boundary: anything still held (a busy flush stopped earlier)
161
+ // must be released here, and receipts confirmed against the finalized projection.
162
+ pi.on("agent_settled", (_event, ctx) => {
163
+ manager.confirmLeaderMessages(ctx);
164
+ manager.flushPendingNotifications(ctx);
165
+ });
134
166
  pi.on("before_agent_start", () => {
135
167
  presentation.sync();
136
168
  });
@@ -226,13 +258,15 @@ export default function (pi: ExtensionAPI) {
226
258
  if (awaited.intercom.some((m) => m.kind === "ask")) break;
227
259
  }
228
260
 
261
+ const summary = makeSummaryWithCoverage(run);
262
+ manager.markAwaitCoverage(run, summary.coveredTaskIds);
229
263
  const asks = intercom.filter((m) => m.kind === "ask");
230
- const heard = intercom.filter((m) => m.kind !== "ask");
264
+ const heard = unreportedIntercom(run, intercom, summary.coveredTaskIds).filter(
265
+ (message) => message.kind !== "ask",
266
+ );
231
267
  const text = [
232
- makeSummary(run),
233
- heard.length > 0
234
- ? `\nIntercom while waiting:\n${heard.map((m) => `- [${m.kind}] ${m.agent} (${m.taskId}): ${truncateText(m.text)}`).join("\n")}`
235
- : "",
268
+ summary.text,
269
+ heard.length > 0 ? `\nIntercom while waiting:\n${heard.map(intercomLine).join("\n")}` : "",
236
270
  asks.length > 0
237
271
  ? `\n${asks.length} child(ren) waiting for your answer:\n${asks
238
272
  .map(
@@ -384,13 +418,11 @@ export default function (pi: ExtensionAPI) {
384
418
  if (!awaited) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
385
419
  const { run, intercom } = awaited;
386
420
  if (!run) return { content: [{ type: "text", text: `Unknown runId: ${runId}` }], isError: true, details: {} };
387
- const intercomText =
388
- intercom.length > 0
389
- ? `\n\nIntercom while waiting:\n${intercom
390
- .map((m) => `- [${m.kind}] ${m.agent} (${m.taskId}): ${truncateText(m.text)}`)
391
- .join("\n")}`
392
- : "";
393
- return { content: [{ type: "text", text: makeSummary(run) + intercomText }], details: { run } };
421
+ const summary = makeSummaryWithCoverage(run);
422
+ manager.markAwaitCoverage(run, summary.coveredTaskIds);
423
+ const heard = unreportedIntercom(run, intercom, summary.coveredTaskIds);
424
+ const intercomText = heard.length > 0 ? `\n\nIntercom while waiting:\n${heard.map(intercomLine).join("\n")}` : "";
425
+ return { content: [{ type: "text", text: summary.text + intercomText }], details: { run } };
394
426
  },
395
427
  });
396
428