@underactive/pi-topping-moa-fusion 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.1]
6
+
7
+ ### Added
8
+
9
+ - `write_plan`, `enter_plan_mode`, `exit_plan_mode`, and `mf_plan_subagent` now declare a pi 0.99 `outputSchema` and return matching `structuredContent` for RPC/JSON consumers, `tool_result` handlers, and codemode scripts: per-agent status, exit code, stop reason, usage, and capped output for subagent runs, and the approval outcome and kickoff route for plan exit. Model-facing text is unchanged.
10
+ - The same four tools now declare MCP-style `annotations`, so permission extensions see accurate destructive and idempotent hints. None claims to be read-only.
11
+
12
+ ### Changed
13
+
14
+ - Updated all four pi development dependencies from 0.87.0 to 0.99.2.
15
+ - Plan-only tools stay out of normal mode by being removed from the active tool set, not through pi 0.99's `prepareLoadout`, which leaves hidden tools active and callable.
16
+
17
+ ### Fixed
18
+
19
+ - Plan-only tools are now also removed on the first turn after leaving plan mode. Previously the exit-reminder turn skipped the leak repair, so a leaked `write_plan`, `exit_plan_mode`, or `mf_plan_subagent` stayed declared for that turn.
20
+
5
21
  ## [0.3.0]
6
22
 
7
23
  ### Added
package/README.md CHANGED
@@ -230,6 +230,8 @@ Outside plan mode, one additional tool is exposed:
230
230
  |------|-------------|
231
231
  | `enter_plan_mode` | Lets the agent enter plan mode itself when asked for a design or plan. Takes the single-model path on the current session model — no pickers, no MoA. Exit and approval work exactly as if the user had invoked `/mf-plan`. |
232
232
 
233
+ All four tools declare pi 0.99 `annotations`, and none claims to be read-only: `write_plan` is `destructiveHint: true, idempotentHint: true`, `exit_plan_mode` is `destructiveHint: true, idempotentHint: false`, `mf_plan_subagent` is `idempotentHint: false`, and `enter_plan_mode` sets only `readOnlyHint: false`. Under the example permission policy in pi's extension docs they are confirmed exactly as before. Each also declares an `outputSchema` and returns matching `structuredContent` for programmatic consumers (RPC/JSON `tool_execution_end` events, `tool_result` handlers, and codemode scripts); for `mf_plan_subagent` that is each agent's status, exit code, stop reason, usage, and output capped at 50 KB. The model-facing text is unchanged, and pi does not save structured values in the session.
234
+
233
235
  ## Subagents
234
236
 
235
237
  Seven agent definitions are auto-installed to `~/.pi/agent/agents/` on first run (won't overwrite existing files). Two belong to the single-model workflow, three to MoA planning, one to the independent opinion flow, and one to the debate flow:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-moa-fusion",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "description": "Pi extension implementing /mf-plan — Claude Code-style 5-phase plan mode with a Mixture-of-Agents proposer/synthesizer pipeline.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -50,10 +50,10 @@
50
50
  "typebox": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "0.87.0",
54
- "@earendil-works/pi-ai": "0.87.0",
55
- "@earendil-works/pi-coding-agent": "0.87.0",
56
- "@earendil-works/pi-tui": "0.87.0",
53
+ "@earendil-works/pi-agent-core": "0.99.2",
54
+ "@earendil-works/pi-ai": "0.99.2",
55
+ "@earendil-works/pi-coding-agent": "0.99.2",
56
+ "@earendil-works/pi-tui": "0.99.2",
57
57
  "@types/node": "^24.0.0",
58
58
  "typebox": "^1.3.3",
59
59
  "typescript": "^5.8.0"
@@ -286,6 +286,32 @@ export function createPlanModeController(pi: ExtensionAPI) {
286
286
  applyReadOnlyProviderEnv();
287
287
  pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
288
288
  };
289
+ /**
290
+ * Plan-only tools are kept out of normal mode by removing them from the
291
+ * active set, not with pi 0.99's `prepareLoadout` / `hiddenDeclarations`:
292
+ * - Hidden tools stay active. `getActiveTools()` would always list
293
+ * write_plan, exit_plan_mode, and mf_plan_subagent, so the contamination
294
+ * checks in `enablePlanModeTools` and `validateToolLoadoutSnapshot` would
295
+ * treat every loadout as poisoned and swap the user's real one for
296
+ * `fullLoadoutFallback()` on every entry and resume, contradicting
297
+ * plan-tool-leak and plan-resume-tool-restore.
298
+ * - Hidden tools stay callable. pi rejects calls to inactive tools; a hidden
299
+ * one would run with only its `isEnabled` guard in the way. It cannot
300
+ * replace the plan-mode read-only narrowing either: hidden bash/edit/write
301
+ * would still run.
302
+ * - The hook runs only while its host tool is active. Outside plan mode that
303
+ * is enter_plan_mode alone; drop it via --tools or the tool picker and all
304
+ * three declarations leak.
305
+ * - After resume or /tree, 0.99.2 strips the hidden declarations only while
306
+ * the host tool is active. The model still sees the plan-only tools when
307
+ * the host is inactive, on pre-0.99 hosts (peerDependencies are "*" and
308
+ * they ignore the hook), and through earlier tool calls in history, which
309
+ * the projection does not filter.
310
+ * - A capability gate would only add surface: older hosts still need this
311
+ * path, so both mechanisms would ship.
312
+ * - The saving is two tool-change deltas per planning round, and this is
313
+ * already a no-op when the set is clean.
314
+ */
289
315
  const deactivatePlanOnlyTools = (): void => {
290
316
  const active = pi.getActiveTools();
291
317
  const filtered = active.filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name));
@@ -696,6 +722,9 @@ export function createPlanModeController(pi: ExtensionAPI) {
696
722
  onBeforeAgentStart: async () => {
697
723
  if (needsExitReminder) {
698
724
  needsExitReminder = false;
725
+ // The reminder turn is also the first normal-mode turn: repair a
726
+ // leaked plan-only loadout here too, not from the next turn on.
727
+ if (!planModeEnabled) deactivatePlanOnlyTools();
699
728
  persistState();
700
729
  return { message: { customType: PLAN_EXIT_CONTEXT_TYPE, content: buildPlanModeExitInstructions(), display: false } };
701
730
  }
@@ -12,9 +12,10 @@ import { modelRefLabel, TRIGGER_TURN, type ModelRef, type ThinkingLevel } from "
12
12
  import { showMoaModelPicker, showImplementingModelPicker } from "../../ui/moaModelPicker.ts";
13
13
  import { showMoaSetup } from "../../ui/moaSetupOverlay.ts";
14
14
  import { showPromptEditor } from "../../ui/promptEditor.ts";
15
- import { getPlan } from "../planFile.ts";
15
+ import { getPlan, getPlanFilePath } from "../planFile.ts";
16
16
  import { isAskUserQuestionInstalled } from "../askUserQuestion.ts";
17
17
  import { buildPlanModeInstructions, buildPlanModeReentryInstructions } from "../instructions.ts";
18
+ import { ENTER_PLAN_MODE_OUTPUT_SCHEMA } from "./structuredResults.ts";
18
19
 
19
20
  export interface InteractivePlanModeHost {
20
21
  beginInteractive(ctx: ExtensionContext): void;
@@ -136,6 +137,11 @@ export function registerEnterPlanModeTool(pi: ExtensionAPI, host: EnterPlanModeH
136
137
  Type.String({ description: "Short description of what is being planned (used to name the plan file)" }),
137
138
  ),
138
139
  }),
140
+ outputSchema: ENTER_PLAN_MODE_OUTPUT_SCHEMA,
141
+ // Swaps the active tools and provider env, persists state, and with a
142
+ // plan_prompt writes the prompt record under .pi/mf-plan/. Not read-only;
143
+ // destructive is left at the default because that write can overwrite.
144
+ annotations: { readOnlyHint: false },
139
145
  // enter_plan_mode is paired with a same-message ask_user_question only in
140
146
  // the single-model path; marking it sequential means the questionnaire
141
147
  // resolves first, so mf-plan's own overlays never draw over it.
@@ -159,6 +165,7 @@ export function registerEnterPlanModeTool(pi: ExtensionAPI, host: EnterPlanModeH
159
165
  type: "text",
160
166
  text: `Plan mode enabled (single model — the current session model). Mutating tools are disabled until your plan is approved via exit_plan_mode.\n\n${instructions}`,
161
167
  }],
168
+ structuredContent: { status: "entered", mode: "single", reentry, planFilePath: getPlanFilePath() },
162
169
  };
163
170
  },
164
171
  });
@@ -10,6 +10,7 @@ import { showPlanReview } from "../../ui/planReviewOverlay.ts";
10
10
  import { planNamingOverlay, withWorkingOverlay } from "../../ui/workingOverlay.ts";
11
11
  import { ASK_USER_QUESTION_PENDING_MESSAGE } from "../askUserQuestion.ts";
12
12
  import { getPlan, getPlanFilePath, saveRepoPlanFile, slugifyPlanName, writePlan } from "../planFile.ts";
13
+ import { EXIT_PLAN_MODE_OUTPUT_SCHEMA, type ExitPlanModeStructuredResult } from "./structuredResults.ts";
13
14
 
14
15
  import type { ImplementationHandoff } from "../../moa/implementationRetry.ts";
15
16
 
@@ -35,6 +36,10 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
35
36
  label: "Exit Plan Mode",
36
37
  description: "Use when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval. Reads the plan from the plan file — does NOT take plan content as a parameter. Only use for tasks that require writing code; not for pure research.",
37
38
  parameters: Type.Object({}),
39
+ outputSchema: EXIT_PLAN_MODE_OUTPUT_SCHEMA,
40
+ // Restores tools and env and queues implementation; the edit branch and the
41
+ // non-TUI approve overwrite plan files, and every call reopens approval.
42
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false },
38
43
  // exit_plan_mode opens a model/UI surface the model may not have asked
39
44
  // the user about yet; marking it sequential means a same-message
40
45
  // ask_user_question resolves first, so approval never lands on unread
@@ -52,6 +57,14 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
52
57
 
53
58
  const plan = getPlan();
54
59
  const filePath = getPlanFilePath();
60
+ // Read at return time: the slug may be named during this call.
61
+ const structuredResult = (
62
+ status: ExitPlanModeStructuredResult["status"],
63
+ kickoff: ExitPlanModeStructuredResult["kickoff"],
64
+ ): ExitPlanModeStructuredResult => {
65
+ const repoPlanSlug = host.getPlanRepoSlug();
66
+ return { status, planFilePath: filePath, headless: !ctx.hasUI, kickoff, ...(repoPlanSlug ? { repoPlanSlug } : {}) };
67
+ };
55
68
 
56
69
  if (!plan || plan.trim() === "") {
57
70
  return {
@@ -95,6 +108,7 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
95
108
  text: buildImplementationKickoffMessage(plan, filePath),
96
109
  },
97
110
  ],
111
+ structuredContent: structuredResult("approved", "inline"),
98
112
  };
99
113
  }
100
114
 
@@ -154,10 +168,15 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
154
168
  return {
155
169
  details: undefined,
156
170
  content: [{ type: "text", text: "Plan approved and saved. Plan mode has exited. Stop here — do not write files, run commands, or call any tools in this turn. A follow-up message will tell you to begin implementation." }],
171
+ structuredContent: structuredResult("approved", "follow_up"),
157
172
  };
158
173
  }
159
174
 
160
- return { content: [{ type: "text", text: "User wants to keep refining the plan. Continue working on the plan file and call exit_plan_mode when ready." }], details: undefined };
175
+ return {
176
+ content: [{ type: "text", text: "User wants to keep refining the plan. Continue working on the plan file and call exit_plan_mode when ready." }],
177
+ details: undefined,
178
+ structuredContent: structuredResult("keep_planning", "none"),
179
+ };
161
180
  }
162
181
  },
163
182
  });
@@ -17,6 +17,7 @@ import { parseRef as parseModelRefLabel, type ThinkingLevel } from "../../shared
17
17
  import { activityLoopCount } from "../../ui/agentStatus.ts";
18
18
  import { showCancelOverlay } from "../../ui/cancelOverlay.ts";
19
19
  import { PLAN_SUBAGENT_NAMES } from "./shared.ts";
20
+ import { buildSubagentStructuredResult, MF_PLAN_SUBAGENT_OUTPUT_SCHEMA } from "./structuredResults.ts";
20
21
 
21
22
  const CANCEL_HINT_WIDGET_KEY = "mf-plan-cancel";
22
23
 
@@ -38,7 +39,18 @@ export interface MfPlanSubagentHost {
38
39
  setActiveCancelSession(session: CancelSession | undefined): void;
39
40
  }
40
41
 
41
- export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagentHost): void {
42
+ /**
43
+ * Test-only runner seam. The defaults are the real subprocess runners, which
44
+ * apply the read-only --tools allowlist and the provider env handshake.
45
+ */
46
+ export type MfPlanSubagentDeps = {
47
+ runSingleAgent?: typeof runSingleAgent;
48
+ runParallelAgents?: typeof runParallelAgents;
49
+ };
50
+
51
+ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagentHost, deps: MfPlanSubagentDeps = {}): void {
52
+ const runSingle = deps.runSingleAgent ?? runSingleAgent;
53
+ const runParallel = deps.runParallelAgents ?? runParallelAgents;
42
54
  pi.registerTool({
43
55
  name: "mf_plan_subagent",
44
56
  label: "Moa Plan Subagent",
@@ -51,6 +63,11 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
51
63
  `Default scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
52
64
  ].join(" "),
53
65
 
66
+ outputSchema: MF_PLAN_SUBAGENT_OUTPUT_SCHEMA,
67
+ // Installs agent files, writes temp prompts, and spawns paid subprocesses
68
+ // whose read-only boundary is cooperative (hence the tripwire), so no
69
+ // read-only claim; every call starts new, nondeterministic runs.
70
+ annotations: { readOnlyHint: false, idempotentHint: false },
54
71
  // mf_plan_subagent can share a message with ask_user_question; marking it
55
72
  // sequential means the questionnaire resolves first so its overlay never
56
73
  // draws over a live questionnaire.
@@ -184,7 +201,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
184
201
  ...agentExtensionOptions(t.agent),
185
202
  signal: run.add(`${t.agent} #${i + 1}`, signal).signal,
186
203
  }));
187
- const results = await runParallelAgents(
204
+ const results = await runParallel(
188
205
  ctx.cwd,
189
206
  agents,
190
207
  taskList,
@@ -208,7 +225,9 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
208
225
  );
209
226
 
210
227
  // resolveOnAbort makes the runner resolve on abort; rethrow so a
211
- // whole-turn abort (pi's ESC) keeps its original semantics.
228
+ // whole-turn abort (pi's ESC) keeps its original semantics, which also
229
+ // means it carries no structured result. A child abort the user did not
230
+ // ask for is reported per agent as "aborted".
212
231
  if (signal?.aborted) throw new Error("Subagent was aborted");
213
232
 
214
233
  const successCount = results.filter((r) => !isFailedResult(r)).length;
@@ -223,6 +242,8 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
223
242
  });
224
243
 
225
244
  const header = `Parallel: ${successCount}/${results.length} succeeded${cancelledCount > 0 ? `, ${cancelledCount} cancelled by user` : ""}`;
245
+ // Subagent usage stays out of the tool result's `usage` so session
246
+ // token accounting is unchanged; it is reported per agent instead.
226
247
  return {
227
248
  details: undefined,
228
249
  content: [
@@ -231,6 +252,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
231
252
  text: `${header}\n\n${summaries.join("\n\n---\n\n")}`,
232
253
  },
233
254
  ],
255
+ structuredContent: buildSubagentStructuredResult("parallel", results),
234
256
  };
235
257
  } finally {
236
258
  unsubscribeF4?.();
@@ -261,7 +283,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
261
283
  : undefined;
262
284
  setCancelHint(ctx, "f4: cancel mf-plan agent");
263
285
  try {
264
- const result = await runSingleAgent(
286
+ const result = await runSingle(
265
287
  ctx.cwd,
266
288
  agents,
267
289
  params.agent!,
@@ -277,22 +299,26 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
277
299
 
278
300
  if (signal?.aborted) throw new Error("Subagent was aborted");
279
301
 
302
+ const structuredContent = buildSubagentStructuredResult("single", [result]);
280
303
  if (result.cancelled) {
281
304
  return {
282
305
  details: undefined,
283
306
  content: [{ type: "text", text: "The user cancelled this subagent. Do not retry; continue planning with the information you already have." }],
307
+ structuredContent,
284
308
  };
285
309
  }
286
310
  if (isFailedResult(result)) {
287
311
  return {
288
312
  details: undefined,
289
313
  content: [{ type: "text", text: `Agent failed: ${getResultOutput(result)}` }],
314
+ structuredContent,
290
315
  isError: true,
291
316
  };
292
317
  }
293
318
  return {
294
319
  details: undefined,
295
320
  content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
321
+ structuredContent,
296
322
  };
297
323
  } finally {
298
324
  unsubscribeF4?.();
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Output schemas and structured results for the plan-mode tools (pi 0.99
3
+ * `outputSchema` / `structuredContent`).
4
+ *
5
+ * These values are not model-facing and are not saved in the session: pi sends
6
+ * only `content` to the model and leaves `structuredContent` out of the
7
+ * persisted tool-result message. They reach live programmatic consumers only —
8
+ * `tool_execution_end` events (RPC/JSON harnesses), `tool_result` handlers, and
9
+ * codemode scripts. Each tool's text `content` is unchanged and comes from the
10
+ * same `isFailedResult` / `getResultOutput` / `truncateOutput` calls as the
11
+ * values here, so the two cannot disagree.
12
+ *
13
+ * `classifySubagentResult` is the one status derivation over `SingleResult`;
14
+ * any other consumer of runner results can import it.
15
+ */
16
+
17
+ import { Type, type Static } from "typebox";
18
+
19
+ import { getResultOutput, isFailedResult, truncateOutput, type SingleResult } from "../../runtime/results.ts";
20
+
21
+ const SubagentUsageSchema = Type.Object({
22
+ input: Type.Number({ description: "Input tokens" }),
23
+ output: Type.Number({ description: "Output tokens" }),
24
+ cacheRead: Type.Number({ description: "Cache-read tokens" }),
25
+ cacheWrite: Type.Number({ description: "Cache-write tokens" }),
26
+ cacheWrite1h: Type.Number({ description: "One-hour cache-write tokens" }),
27
+ cost: Type.Number({ description: "Total cost reported by the provider" }),
28
+ turns: Type.Number({ description: "Assistant turns" }),
29
+ toolCalls: Type.Number({ description: "Tool calls the agent made" }),
30
+ contextTokens: Type.Optional(Type.Number({ description: "Context size of the latest turn, when reported" })),
31
+ }, { additionalProperties: false, description: "Token usage and cost the runner recorded for the agent" });
32
+
33
+ const SubagentAgentResultSchema = Type.Object({
34
+ agent: Type.String({ description: "Agent name" }),
35
+ status: Type.Union([
36
+ Type.Literal("completed"),
37
+ Type.Literal("failed"),
38
+ Type.Literal("cancelled"),
39
+ Type.Literal("aborted"),
40
+ ], { description: "cancelled: stopped by the user; aborted: the child stopped without a user cancel; failed: nonzero exit, provider error, or no messages" }),
41
+ exitCode: Type.Number({ description: "Child process exit code" }),
42
+ stopReason: Type.Optional(Type.String({ description: "Stop reason of the agent's last assistant message" })),
43
+ model: Type.Optional(Type.String({ description: "Model the agent ran on" })),
44
+ output: Type.String({ description: "Final answer, or the error for an agent that did not complete, capped at 50 KB" }),
45
+ truncated: Type.Boolean({ description: "Whether `output` was cut at the 50 KB cap" }),
46
+ usage: SubagentUsageSchema,
47
+ }, { additionalProperties: false });
48
+
49
+ export const MF_PLAN_SUBAGENT_OUTPUT_SCHEMA = Type.Object({
50
+ mode: Type.Union([Type.Literal("single"), Type.Literal("parallel")], { description: "single: one agent from agent + task; parallel: the tasks array" }),
51
+ status: Type.Union([
52
+ Type.Literal("completed"),
53
+ Type.Literal("partial"),
54
+ Type.Literal("failed"),
55
+ Type.Literal("cancelled"),
56
+ Type.Literal("aborted"),
57
+ ], { description: "completed: every agent completed; partial: some did; otherwise failed, aborted, or cancelled, in that order of precedence" }),
58
+ total: Type.Number({ description: "Agents requested" }),
59
+ succeeded: Type.Number({ description: "Agents that completed" }),
60
+ failed: Type.Number({ description: "Agents that failed" }),
61
+ cancelled: Type.Number({ description: "Agents the user cancelled" }),
62
+ aborted: Type.Number({ description: "Agents that stopped without a user cancel" }),
63
+ results: Type.Array(SubagentAgentResultSchema, { description: "One entry per requested agent, in request order" }),
64
+ }, { additionalProperties: false });
65
+
66
+ export const WRITE_PLAN_OUTPUT_SCHEMA = Type.Object({
67
+ status: Type.Literal("written", { description: "The plan file was written" }),
68
+ filePath: Type.String({ description: "Plan file whose content was replaced" }),
69
+ length: Type.Number({ description: "Length of the written plan in characters" }),
70
+ }, { additionalProperties: false });
71
+
72
+ export const ENTER_PLAN_MODE_OUTPUT_SCHEMA = Type.Object({
73
+ status: Type.Literal("entered", { description: "Plan mode is now on" }),
74
+ mode: Type.Literal("single", { description: "The tool always starts single-model planning; MoA planning starts from /mf-plan" }),
75
+ reentry: Type.Boolean({ description: "Whether a plan file already existed, so planning continues it" }),
76
+ planFilePath: Type.String({ description: "Plan file write_plan writes to" }),
77
+ }, { additionalProperties: false });
78
+
79
+ export const EXIT_PLAN_MODE_OUTPUT_SCHEMA = Type.Object({
80
+ status: Type.Union([Type.Literal("approved"), Type.Literal("keep_planning")], { description: "approved: plan mode has exited; keep_planning: the user sent the plan back and plan mode is still on" }),
81
+ planFilePath: Type.String({ description: "Plan file that was reviewed" }),
82
+ headless: Type.Boolean({ description: "Whether the plan was auto-approved without a UI (MOA_PLAN_AUTO_APPROVE=1)" }),
83
+ kickoff: Type.Union([
84
+ Type.Literal("inline"),
85
+ Type.Literal("follow_up"),
86
+ Type.Literal("none"),
87
+ ], { description: "inline: this result is the implementation kickoff; follow_up: the kickoff is queued as the next user message; none: no implementation was started" }),
88
+ repoPlanSlug: Type.Optional(Type.String({ description: "Name of the plan's copies under .pi/mf-plan/" })),
89
+ }, { additionalProperties: false });
90
+
91
+ export type MfPlanSubagentStructuredResult = Static<typeof MF_PLAN_SUBAGENT_OUTPUT_SCHEMA>;
92
+ export type SubagentAgentResult = Static<typeof SubagentAgentResultSchema>;
93
+ export type SubagentAgentStatus = SubagentAgentResult["status"];
94
+ export type WritePlanStructuredResult = Static<typeof WRITE_PLAN_OUTPUT_SCHEMA>;
95
+ export type EnterPlanModeStructuredResult = Static<typeof ENTER_PLAN_MODE_OUTPUT_SCHEMA>;
96
+ export type ExitPlanModeStructuredResult = Static<typeof EXIT_PLAN_MODE_OUTPUT_SCHEMA>;
97
+
98
+ /**
99
+ * A user cancel wins over the stop reason it produces; an `aborted` stop
100
+ * without one is a child abort nobody asked for. The runner marks every
101
+ * cancelled result failed, so `completed` is exactly `!isFailedResult`.
102
+ */
103
+ export function classifySubagentResult(result: SingleResult): SubagentAgentStatus {
104
+ if (result.cancelled) return "cancelled";
105
+ if (result.stopReason === "aborted") return "aborted";
106
+ return isFailedResult(result) ? "failed" : "completed";
107
+ }
108
+
109
+ function aggregateStatus(statuses: readonly SubagentAgentStatus[]): MfPlanSubagentStructuredResult["status"] {
110
+ const completed = statuses.filter((status) => status === "completed").length;
111
+ if (completed === statuses.length) return "completed";
112
+ if (completed > 0) return "partial";
113
+ if (statuses.includes("failed")) return "failed";
114
+ if (statuses.includes("aborted")) return "aborted";
115
+ return "cancelled";
116
+ }
117
+
118
+ function toAgentResult(result: SingleResult): SubagentAgentResult {
119
+ const fullOutput = getResultOutput(result);
120
+ const output = truncateOutput(fullOutput);
121
+ const { usage } = result;
122
+ return {
123
+ agent: result.agent,
124
+ status: classifySubagentResult(result),
125
+ exitCode: result.exitCode,
126
+ ...(result.stopReason ? { stopReason: result.stopReason } : {}),
127
+ ...(result.model ? { model: result.model } : {}),
128
+ output,
129
+ truncated: output !== fullOutput,
130
+ usage: {
131
+ input: usage.input,
132
+ output: usage.output,
133
+ cacheRead: usage.cacheRead,
134
+ cacheWrite: usage.cacheWrite,
135
+ cacheWrite1h: usage.cacheWrite1h,
136
+ cost: usage.cost,
137
+ turns: usage.turns,
138
+ toolCalls: usage.toolCalls,
139
+ ...(usage.contextTokens !== undefined ? { contextTokens: usage.contextTokens } : {}),
140
+ },
141
+ };
142
+ }
143
+
144
+ /** Results stay in request order; a single-mode run's status is its agent's status. */
145
+ export function buildSubagentStructuredResult(
146
+ mode: MfPlanSubagentStructuredResult["mode"],
147
+ results: readonly SingleResult[],
148
+ ): MfPlanSubagentStructuredResult {
149
+ const entries = results.map(toAgentResult);
150
+ const count = (status: SubagentAgentStatus): number => entries.filter((entry) => entry.status === status).length;
151
+ return {
152
+ mode,
153
+ status: aggregateStatus(entries.map((entry) => entry.status)),
154
+ total: entries.length,
155
+ succeeded: count("completed"),
156
+ failed: count("failed"),
157
+ cancelled: count("cancelled"),
158
+ aborted: count("aborted"),
159
+ results: entries,
160
+ };
161
+ }
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { Type } from "typebox";
3
3
 
4
4
  import { getPlanFilePath, writePlan } from "../planFile.ts";
5
+ import { WRITE_PLAN_OUTPUT_SCHEMA } from "./structuredResults.ts";
5
6
 
6
7
  export function registerWritePlanTool(pi: ExtensionAPI, isEnabled: () => boolean): void {
7
8
  pi.registerTool({
@@ -11,6 +12,9 @@ export function registerWritePlanTool(pi: ExtensionAPI, isEnabled: () => boolean
11
12
  parameters: Type.Object({
12
13
  content: Type.String({ description: "The full content to write to the plan file (replaces existing content)" }),
13
14
  }),
15
+ outputSchema: WRITE_PLAN_OUTPUT_SCHEMA,
16
+ // Replaces the whole plan file; the same content yields a byte-identical file.
17
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true },
14
18
 
15
19
  async execute(_toolCallId, params, _signal, _onUpdate, _ctx) {
16
20
  if (!isEnabled()) {
@@ -27,6 +31,7 @@ export function registerWritePlanTool(pi: ExtensionAPI, isEnabled: () => boolean
27
31
  return {
28
32
  content: [{ type: "text", text: `Plan written to ${filePath} (${params.content.length} chars)` }],
29
33
  details: { filePath, length: params.content.length },
34
+ structuredContent: { status: "written", filePath, length: params.content.length },
30
35
  };
31
36
  },
32
37
  });