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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,35 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.2]
6
+
7
+ ### Changed
8
+
9
+ - Verified compatibility with pi 1.0.0: no pi export this extension imports was removed or changed, and typecheck and the full test suite pass against 1.0.0.
10
+ - Updated all four pi development dependencies from 0.99.2 to 1.0.0.
11
+
12
+ ### Fixed
13
+
14
+ - The MoA progress footer now keeps one cumulative elapsed time across fan-out, synthesis, implementation, verification, and verifier-driven repair rounds, while excluding time spent waiting for user input.
15
+ - Other sessions no longer run `moa-verifier` as a general-purpose subagent. Earlier versions installed `moa-proposer`, `moa-synthesizer`, and `moa-verifier` into `~/.pi/agent/agents/`, which subagent tools such as pi-subagents' `Agent` scan in every session, so a session outside `/mf-plan` could hand the verifier unrelated work. These three agents are no longer installed, and copies left by earlier versions are removed at session start. MoA runs already loaded them from the extension package, so edits to those copies never took effect.
16
+ - The `mf_plan_subagent` tool description now states its structured result shape, including the fields of each `results` entry. pi 1.0.0 codemode shortens each tool's declared output to one line of top-level field names, which left `results` as a bare name; the tool description is the one part of that summary kept verbatim, so script authors see the nested shape without calling `describeTool()`. The structured result itself is unchanged.
17
+
18
+ ## [0.4.1]
19
+
20
+ ### Added
21
+
22
+ - `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.
23
+ - The same four tools now declare MCP-style `annotations`, so permission extensions see accurate destructive and idempotent hints. None claims to be read-only.
24
+
25
+ ### Changed
26
+
27
+ - Updated all four pi development dependencies from 0.87.0 to 0.99.2.
28
+ - 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.
29
+
30
+ ### Fixed
31
+
32
+ - 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.
33
+
5
34
  ## [0.3.0]
6
35
 
7
36
  ### Added
package/README.md CHANGED
@@ -99,7 +99,7 @@ Several safeguards target known LLM judging biases:
99
99
  - **Auditable reasoning** — the plan opens with a Context section recording dimension reasoning, proposer alignment, and synthesis decisions, so recombination choices are inspectable. `### Proposer alignment` labels support as evidence-backed or asserted, and conflict recommendation Details lines include distinct-model support counts. Reconciliation commentary is confined there; the rest reads as one coherent plan. The orchestrator verifies all three subsections are present and rejects a first plan that omits them (one corrective retry, then a warning), the same way it enforces `## Proposer Verdicts`.
100
100
  - **Disagreements go to the user** — every substantive disagreement must be emitted as a `## Conflicts` block, even when the synthesizer is confident, so its judgment is a default you can override rather than a decision made for you. Genuine ambiguity about *your intent* instead produces a single `## Open Question`, and the synthesizer is re-run with your answer.
101
101
 
102
- **Protocol-internal, always current at runtime.** The proposer, synthesizer, and verifier prompts are tightly coupled to this extension's parsers and UI (e.g. the `## Conflicts` markup, and the verifier's `**Verdict:**`/`### Gaps` markup). A hand-edited copy under `~/.pi/agent/agents/` is still written for inspection, but MoA runs always use the bundled definition from the installed extension, so a protocol update can never desync from what the subprocess runs. `moa-explore` and `mf-plan` remain fully user-customizable.
102
+ **Protocol-internal, always current at runtime.** The proposer, synthesizer, and verifier prompts are tightly coupled to this extension's parsers and UI (e.g. the `## Conflicts` markup, and the verifier's `**Verdict:**`/`### Gaps` markup). MoA runs always load them from the installed extension's bundled `agents/` directory, so a protocol update can never desync from what the subprocess runs. They are never installed into `~/.pi/agent/agents/`: subagent tools in every session scan that directory and would offer them as general-purpose agents outside `/mf-plan`. Copies left there by earlier versions are removed at session start. `moa-explore` and `mf-plan` remain fully user-customizable.
103
103
 
104
104
  ### Implementation failures
105
105
 
@@ -230,9 +230,11 @@ 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
- 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:
237
+ Seven agent definitions ship with the extension. Two belong to the single-model workflow, three to MoA planning, one to the independent opinion flow, and one to the debate flow. The four outside MoA planning (`moa-explore`, `mf-plan`, `moa-opinion`, `moa-debater`) are auto-installed to `~/.pi/agent/agents/` on first run (won't overwrite existing files). The three MoA agents always load from the extension itself and are never installed (see [How the synthesizer works](#how-the-synthesizer-works)):
236
238
 
237
239
  | Agent | Workflow | Role | Model & thinking come from |
238
240
  |-------|----------|------|----------------------------|
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.2",
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": "1.0.0",
54
+ "@earendil-works/pi-ai": "1.0.0",
55
+ "@earendil-works/pi-coding-agent": "1.0.0",
56
+ "@earendil-works/pi-tui": "1.0.0",
57
57
  "@types/node": "^24.0.0",
58
58
  "typebox": "^1.3.3",
59
59
  "typescript": "^5.8.0"
@@ -15,6 +15,14 @@ import { parseAgentFile, type AgentConfig } from "./discovery.ts";
15
15
  */
16
16
  export const AUTHORITATIVE_MOA_AGENT_NAMES = Object.freeze(["moa-proposer", "moa-synthesizer", "moa-verifier"]);
17
17
 
18
+ /**
19
+ * The user-facing agents seeded into `~/.pi/agent/agents/` for customization.
20
+ * That directory is shared: every subagent tool scans it in every session
21
+ * (e.g. pi-subagents' `Agent`), so anything installed there is offered as a
22
+ * general-purpose agent outside `/mf-plan`.
23
+ */
24
+ const INSTALLED_AGENT_NAMES = Object.freeze(["moa-explore", "mf-plan", "moa-opinion", "moa-debater"]);
25
+
18
26
  const MAX_WALK_UP_LEVELS = 5;
19
27
 
20
28
  /**
@@ -67,19 +75,22 @@ export function withAuthoritativeMoaAgents(agents: AgentConfig[], shippedDir: st
67
75
  return result;
68
76
  }
69
77
 
78
+ /**
79
+ * Seed the user-facing agents (no-clobber) and remove the protocol-internal
80
+ * copies earlier versions installed alongside them. MoA runs load the
81
+ * protocol agents from the bundled `agents/` directory instead — see
82
+ * withAuthoritativeMoaAgents.
83
+ */
70
84
  export function installShippedAgents(): void {
71
85
  const agentsDir = path.join(getAgentDir(), "agents");
72
86
  fs.mkdirSync(agentsDir, { recursive: true });
73
87
 
74
- // This still writes user-visible files for direct inspection/customization,
75
- // but note `moa-proposer`/`moa-synthesizer` are no longer authoritative at
76
- // runtime once written here — see withAuthoritativeMoaAgents.
77
88
  const sourceDir = shippedAgentsDir();
78
- for (const name of ["moa-explore.md", "mf-plan.md", "moa-opinion.md", "moa-debater.md", "moa-proposer.md", "moa-synthesizer.md", "moa-verifier.md"]) {
79
- const targetPath = path.join(agentsDir, name);
89
+ for (const name of INSTALLED_AGENT_NAMES) {
90
+ const targetPath = path.join(agentsDir, `${name}.md`);
80
91
  if (fs.existsSync(targetPath)) continue; // don't clobber
81
92
 
82
- const sourcePath = path.join(sourceDir, name);
93
+ const sourcePath = path.join(sourceDir, `${name}.md`);
83
94
  if (fs.existsSync(sourcePath)) {
84
95
  try {
85
96
  fs.copyFileSync(sourcePath, targetPath);
@@ -88,4 +99,35 @@ export function installShippedAgents(): void {
88
99
  }
89
100
  }
90
101
  }
102
+
103
+ removeInstalledAuthoritativeAgents(agentsDir, sourceDir);
104
+ }
105
+
106
+ /**
107
+ * Delete installed copies of the protocol-internal agents from `agentsDir`.
108
+ * Other sessions' subagent tools picked them up there (`moa-verifier` ran for
109
+ * unrelated verification work), and MoA runs never read them. Only a file that
110
+ * still declares the agent's own name is removed, and nothing is removed when
111
+ * `agentsDir` resolves to `shippedDir`, whose files are the bundled definitions.
112
+ */
113
+ export function removeInstalledAuthoritativeAgents(agentsDir: string, shippedDir: string): void {
114
+ if (isSameDirectory(agentsDir, shippedDir)) return;
115
+
116
+ for (const name of AUTHORITATIVE_MOA_AGENT_NAMES) {
117
+ const targetPath = path.join(agentsDir, `${name}.md`);
118
+ try {
119
+ if (parseAgentFile(targetPath, "user")?.name !== name) continue;
120
+ fs.unlinkSync(targetPath);
121
+ } catch {
122
+ // Malformed frontmatter, or another session removed it first.
123
+ }
124
+ }
125
+ }
126
+
127
+ function isSameDirectory(a: string, b: string): boolean {
128
+ try {
129
+ return fs.realpathSync(a) === fs.realpathSync(b);
130
+ } catch {
131
+ return false;
132
+ }
91
133
  }
@@ -22,8 +22,8 @@ export interface ConfigurableAgent {
22
22
  /**
23
23
  * The shipped agents whose model the user may choose. `moa-proposer` and
24
24
  * `moa-synthesizer` are excluded: their model is assigned per slot at runtime,
25
- * and withAuthoritativeMoaAgents replaces their on-disk copies wholesale, so
26
- * any frontmatter written to them would be ignored. `mf-plan` is excluded
25
+ * and they load from the bundled definitions without ever being installed, so
26
+ * there is no on-disk frontmatter to write. `mf-plan` is excluded
27
27
  * too: it follows the session model chosen when entering plan mode, so a
28
28
  * configured default would never be read.
29
29
  */
@@ -221,10 +221,14 @@ export async function runImplementationRetryFlow(
221
221
  "Continue manually",
222
222
  ];
223
223
 
224
- const selection = await ctx.ui.select(
224
+ const widget = host.getActiveProgressWidget();
225
+ const selection = await (widget?.whileWaitingForUser?.(() => ctx.ui.select(
225
226
  "Implementation failed — the plan is still available. What next?",
226
227
  options,
227
- );
228
+ )) ?? ctx.ui.select(
229
+ "Implementation failed — the plan is still available. What next?",
230
+ options,
231
+ ));
228
232
 
229
233
  if (!selection || selection === "Continue manually") {
230
234
  host.stopActiveProgressWidget();
@@ -246,11 +250,15 @@ export async function runImplementationRetryFlow(
246
250
  }
247
251
 
248
252
  if (selection === "Choose a different model") {
249
- const newSelection = await showImplementingModelPicker(
253
+ const newSelection = await (widget?.whileWaitingForUser?.(() => showImplementingModelPicker(
250
254
  ctx,
251
255
  host.currentThinkingLevel(),
252
256
  "Implementation failed — choose a different model",
253
- );
257
+ )) ?? showImplementingModelPicker(
258
+ ctx,
259
+ host.currentThinkingLevel(),
260
+ "Implementation failed — choose a different model",
261
+ ));
254
262
 
255
263
  if (!newSelection) {
256
264
  // User cancelled model picker; re-run retry flow
@@ -271,7 +279,6 @@ export async function runImplementationRetryFlow(
271
279
  host.setImplementationHandoff(updatedHandoff);
272
280
  host.markImplementationPending(ctx);
273
281
 
274
- const widget = host.getActiveProgressWidget();
275
282
  if (widget) {
276
283
  const moaInfo = host.getActiveRunMoaInfo();
277
284
  widget.setPhaseModels({
@@ -32,27 +32,28 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
32
32
  });
33
33
  host.persistState();
34
34
  let chatRounds = 0;
35
+ const waitForUser = <T>(task: () => Promise<T>): Promise<T> => widget.whileWaitingForUser?.(task) ?? task();
35
36
 
36
37
  while (true) {
37
38
  session.closeOverlay?.();
38
39
  const synthesizer = getSynthesizer();
39
40
  let decision: PlanReviewDecision;
40
41
  if (ctx.mode === "tui") {
41
- decision = await showPlanReview(
42
+ decision = await waitForUser(() => showPlanReview(
42
43
  ctx,
43
44
  currentPlan,
44
45
  { proposers, synthesizer, proposerPlans, verdictsMarkdown: getLatestVerdicts() ?? undefined },
45
46
  host.getPlanRepoSlug(),
46
47
  chatRounds < MAX_REVIEW_CHAT_ROUNDS,
47
- );
48
+ ));
48
49
  } else {
49
- const choice = await ctx.ui.select("Exit plan mode?", ["Approve — start implementing", "Keep planning", "Edit plan", "Chat with synthesizer"]);
50
+ const choice = await waitForUser(() => ctx.ui.select("Exit plan mode?", ["Approve — start implementing", "Keep planning", "Edit plan", "Chat with synthesizer"]));
50
51
  decision = choice?.startsWith("Approve") ? "approve" : choice?.startsWith("Edit") ? "edit" : choice?.startsWith("Chat") ? "chat" : "keep";
51
52
  }
52
53
 
53
54
  if (decision === "edit") {
54
55
  session.closeOverlay?.();
55
- const edited = await ctx.ui.editor("Edit Plan", currentPlan);
56
+ const edited = await waitForUser(() => ctx.ui.editor("Edit Plan", currentPlan));
56
57
  if (edited?.trim()) {
57
58
  currentPlan = stripSynthSections(edited.trim());
58
59
  writePlan(currentPlan);
@@ -68,7 +69,7 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
68
69
  continue;
69
70
  }
70
71
  session.closeOverlay?.();
71
- const feedback = await ctx.ui.editor("Describe the changes you want:");
72
+ const feedback = await waitForUser(() => ctx.ui.editor("Describe the changes you want:"));
72
73
  if (!feedback?.trim()) continue;
73
74
  chatRounds++;
74
75
  synthConversation.push(`The user reviewed your synthesized plan and gave this feedback:\n${feedback.trim()}\n\nRevise the plan accordingly and re-emit the complete final plan. Drop any ## Conflicts or ## Open Question sections.`);
@@ -121,7 +122,7 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
121
122
  if (roles?.implementer) {
122
123
  implementationSelection = { ref: roles.implementer, thinking: roles.implementerThinking ?? host.currentThinkingLevel() };
123
124
  } else {
124
- implementationSelection = await showImplementingModelPicker(ctx, host.currentThinkingLevel());
125
+ implementationSelection = await waitForUser(() => showImplementingModelPicker(ctx, host.currentThinkingLevel()));
125
126
  }
126
127
  if (implementationSelection) {
127
128
  await host.applyImplementingSelection(
@@ -471,12 +471,13 @@ export async function runVerificationDecision(deps: {
471
471
  ? ["View full findings", "Send verifier findings to the implementer", "Accept implementation as-is"]
472
472
  : ["Send verifier findings to the implementer", "Accept implementation as-is"];
473
473
  while (true) {
474
- const choice = await ctx.ui.select(
474
+ const selectDecision = () => ctx.ui.select(
475
475
  buildVerificationDecisionPrompt(parsed, scriptResults, reportPath, promptWidth),
476
476
  options,
477
477
  );
478
+ const choice = await (widget?.whileWaitingForUser?.(selectDecision) ?? selectDecision());
478
479
  if (choice === "View full findings") {
479
- await showFindings(ctx, report);
480
+ await (widget?.whileWaitingForUser?.(() => showFindings(ctx, report)) ?? showFindings(ctx, report));
480
481
  continue;
481
482
  }
482
483
  if (choice === "Send verifier findings to the implementer") {
@@ -691,10 +692,13 @@ export async function runImplementationVerification(
691
692
  }
692
693
 
693
694
  if (outcome.cancelled) {
694
- const choice = await ctx.ui.select(
695
+ const choice = await (widget?.whileWaitingForUser?.(() => ctx.ui.select(
695
696
  "Verification was cancelled — what next?",
696
697
  ["Retry verification", "Skip verification"],
697
- );
698
+ )) ?? ctx.ui.select(
699
+ "Verification was cancelled — what next?",
700
+ ["Retry verification", "Skip verification"],
701
+ ));
698
702
  if (choice === "Retry verification") return "retry";
699
703
  ctx.ui.notify("Verification skipped. Implementation left as-is.", "warning");
700
704
  finish("cancelled");
@@ -715,10 +719,13 @@ export async function runImplementationVerification(
715
719
  const options = fallbackOption
716
720
  ? [fallbackOption, "Retry verification", "Skip verification"]
717
721
  : ["Retry verification", "Skip verification"];
718
- const choice = await ctx.ui.select(
722
+ const choice = await (widget?.whileWaitingForUser?.(() => ctx.ui.select(
719
723
  `Verification could not complete (${verifierLabel}): ${titleReason}`,
720
724
  options,
721
- );
725
+ )) ?? ctx.ui.select(
726
+ `Verification could not complete (${verifierLabel}): ${titleReason}`,
727
+ options,
728
+ ));
722
729
  if (fallbackOption && choice === fallbackOption) return "fallback";
723
730
  if (choice === "Retry verification") return "retry";
724
731
  ctx.ui.notify("Verification skipped. Implementation left as-is.", "warning");
@@ -788,10 +795,13 @@ export async function runImplementationVerification(
788
795
  finish("error");
789
796
  return;
790
797
  }
791
- const choice = await ctx.ui.select(
798
+ const choice = await (widget?.whileWaitingForUser?.(() => ctx.ui.select(
792
799
  "The verifier produced no parseable verdict — what next?",
793
800
  ["Retry verification", "Skip verification"],
794
- );
801
+ )) ?? ctx.ui.select(
802
+ "The verifier produced no parseable verdict — what next?",
803
+ ["Retry verification", "Skip verification"],
804
+ ));
795
805
  if (choice === "Retry verification") continue;
796
806
  ctx.ui.notify("Verification inconclusive — implementation left as-is.", "warning");
797
807
  finish("error");
@@ -154,6 +154,9 @@ export function createPlanModeController(pi: ExtensionAPI) {
154
154
  // than a module singleton so the fake-`pi` tests stay self-contained and no
155
155
  // state leaks across sessions.
156
156
  const askUserQuestionTracker = createAskUserQuestionTracker(pi, () => {
157
+ const widget = runningProgressWidget ?? activeProgressWidget;
158
+ if (askUserQuestionTracker.isActive()) widget?.pauseElapsed();
159
+ else widget?.resumeElapsed();
157
160
  if (lastFooterCtx) updateStatus(lastFooterCtx);
158
161
  });
159
162
  // Subscribe immediately so the blocked flag is live from extension load, not
@@ -286,6 +289,32 @@ export function createPlanModeController(pi: ExtensionAPI) {
286
289
  applyReadOnlyProviderEnv();
287
290
  pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
288
291
  };
292
+ /**
293
+ * Plan-only tools are kept out of normal mode by removing them from the
294
+ * active set, not with pi 0.99's `prepareLoadout` / `hiddenDeclarations`:
295
+ * - Hidden tools stay active. `getActiveTools()` would always list
296
+ * write_plan, exit_plan_mode, and mf_plan_subagent, so the contamination
297
+ * checks in `enablePlanModeTools` and `validateToolLoadoutSnapshot` would
298
+ * treat every loadout as poisoned and swap the user's real one for
299
+ * `fullLoadoutFallback()` on every entry and resume, contradicting
300
+ * plan-tool-leak and plan-resume-tool-restore.
301
+ * - Hidden tools stay callable. pi rejects calls to inactive tools; a hidden
302
+ * one would run with only its `isEnabled` guard in the way. It cannot
303
+ * replace the plan-mode read-only narrowing either: hidden bash/edit/write
304
+ * would still run.
305
+ * - The hook runs only while its host tool is active. Outside plan mode that
306
+ * is enter_plan_mode alone; drop it via --tools or the tool picker and all
307
+ * three declarations leak.
308
+ * - After resume or /tree, 0.99.2 strips the hidden declarations only while
309
+ * the host tool is active. The model still sees the plan-only tools when
310
+ * the host is inactive, on pre-0.99 hosts (peerDependencies are "*" and
311
+ * they ignore the hook), and through earlier tool calls in history, which
312
+ * the projection does not filter.
313
+ * - A capability gate would only add surface: older hosts still need this
314
+ * path, so both mechanisms would ship.
315
+ * - The saving is two tool-change deltas per planning round, and this is
316
+ * already a no-op when the set is clean.
317
+ */
289
318
  const deactivatePlanOnlyTools = (): void => {
290
319
  const active = pi.getActiveTools();
291
320
  const filtered = active.filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name));
@@ -696,6 +725,9 @@ export function createPlanModeController(pi: ExtensionAPI) {
696
725
  onBeforeAgentStart: async () => {
697
726
  if (needsExitReminder) {
698
727
  needsExitReminder = false;
728
+ // The reminder turn is also the first normal-mode turn: repair a
729
+ // leaked plan-only loadout here too, not from the next turn on.
730
+ if (!planModeEnabled) deactivatePlanOnlyTools();
699
731
  persistState();
700
732
  return { message: { customType: PLAN_EXIT_CONTEXT_TYPE, content: buildPlanModeExitInstructions(), display: false } };
701
733
  }
@@ -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",
@@ -49,8 +61,14 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
49
61
  "Modes: single (agent + task), parallel (tasks array).",
50
62
  "Agents: moa-explore (fast codebase recon), mf-plan (implementation planning).",
51
63
  `Default scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
64
+ "Returns { mode, status, total, succeeded, failed, cancelled, aborted, results }; results is an array of { agent, status, exitCode, stopReason?, model?, output, truncated, usage }.",
52
65
  ].join(" "),
53
66
 
67
+ outputSchema: MF_PLAN_SUBAGENT_OUTPUT_SCHEMA,
68
+ // Installs agent files, writes temp prompts, and spawns paid subprocesses
69
+ // whose read-only boundary is cooperative (hence the tripwire), so no
70
+ // read-only claim; every call starts new, nondeterministic runs.
71
+ annotations: { readOnlyHint: false, idempotentHint: false },
54
72
  // mf_plan_subagent can share a message with ask_user_question; marking it
55
73
  // sequential means the questionnaire resolves first so its overlay never
56
74
  // draws over a live questionnaire.
@@ -184,7 +202,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
184
202
  ...agentExtensionOptions(t.agent),
185
203
  signal: run.add(`${t.agent} #${i + 1}`, signal).signal,
186
204
  }));
187
- const results = await runParallelAgents(
205
+ const results = await runParallel(
188
206
  ctx.cwd,
189
207
  agents,
190
208
  taskList,
@@ -208,7 +226,9 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
208
226
  );
209
227
 
210
228
  // resolveOnAbort makes the runner resolve on abort; rethrow so a
211
- // whole-turn abort (pi's ESC) keeps its original semantics.
229
+ // whole-turn abort (pi's ESC) keeps its original semantics, which also
230
+ // means it carries no structured result. A child abort the user did not
231
+ // ask for is reported per agent as "aborted".
212
232
  if (signal?.aborted) throw new Error("Subagent was aborted");
213
233
 
214
234
  const successCount = results.filter((r) => !isFailedResult(r)).length;
@@ -223,6 +243,8 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
223
243
  });
224
244
 
225
245
  const header = `Parallel: ${successCount}/${results.length} succeeded${cancelledCount > 0 ? `, ${cancelledCount} cancelled by user` : ""}`;
246
+ // Subagent usage stays out of the tool result's `usage` so session
247
+ // token accounting is unchanged; it is reported per agent instead.
226
248
  return {
227
249
  details: undefined,
228
250
  content: [
@@ -231,6 +253,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
231
253
  text: `${header}\n\n${summaries.join("\n\n---\n\n")}`,
232
254
  },
233
255
  ],
256
+ structuredContent: buildSubagentStructuredResult("parallel", results),
234
257
  };
235
258
  } finally {
236
259
  unsubscribeF4?.();
@@ -261,7 +284,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
261
284
  : undefined;
262
285
  setCancelHint(ctx, "f4: cancel mf-plan agent");
263
286
  try {
264
- const result = await runSingleAgent(
287
+ const result = await runSingle(
265
288
  ctx.cwd,
266
289
  agents,
267
290
  params.agent!,
@@ -277,22 +300,26 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
277
300
 
278
301
  if (signal?.aborted) throw new Error("Subagent was aborted");
279
302
 
303
+ const structuredContent = buildSubagentStructuredResult("single", [result]);
280
304
  if (result.cancelled) {
281
305
  return {
282
306
  details: undefined,
283
307
  content: [{ type: "text", text: "The user cancelled this subagent. Do not retry; continue planning with the information you already have." }],
308
+ structuredContent,
284
309
  };
285
310
  }
286
311
  if (isFailedResult(result)) {
287
312
  return {
288
313
  details: undefined,
289
314
  content: [{ type: "text", text: `Agent failed: ${getResultOutput(result)}` }],
315
+ structuredContent,
290
316
  isError: true,
291
317
  };
292
318
  }
293
319
  return {
294
320
  details: undefined,
295
321
  content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
322
+ structuredContent,
296
323
  };
297
324
  } finally {
298
325
  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
  });
@@ -215,6 +215,8 @@ export interface MoaProgressView {
215
215
  phaseModels(): PhaseModels;
216
216
  /** The phase currently highlighted in the band; undefined highlights none. */
217
217
  activePhase(): MoaPhase | undefined;
218
+ /** Total active run time, excluding periods paused for user input. */
219
+ totalElapsedMs(now?: number): number;
218
220
  }
219
221
 
220
222
  export interface MoaProgressCallbacks {
@@ -258,6 +260,12 @@ export class MoaProgressWidget implements MoaProgressView {
258
260
  private tableMounted = false;
259
261
  private showPreview = true;
260
262
  private requestRender: (() => void) | undefined;
263
+ /** Accumulated active run time before the current active interval. */
264
+ private elapsedActiveMs = 0;
265
+ /** Start of the current active interval; undefined while waiting for user input. */
266
+ private elapsedActiveSince: number | undefined;
267
+ /** Nested pause ownership (table unmount plus an explicit prompt may overlap). */
268
+ private elapsedPauseDepth = 0;
261
269
 
262
270
  constructor(
263
271
  ctx: ExtensionContext,
@@ -288,6 +296,8 @@ export class MoaProgressWidget implements MoaProgressView {
288
296
 
289
297
  private mountTable(): void {
290
298
  if (this.tableMounted) return;
299
+ if (this.elapsedActiveSince === undefined && this.elapsedPauseDepth === 0) this.elapsedActiveSince = Date.now();
300
+ else this.resumeElapsed();
291
301
  this.tableMounted = true;
292
302
  this.ui.setWidget(WIDGET_KEY, (tui, theme) => {
293
303
  this.requestRender = () => tui.requestRender();
@@ -297,6 +307,7 @@ export class MoaProgressWidget implements MoaProgressView {
297
307
 
298
308
  private unmount(): void {
299
309
  if (this.tableMounted) {
310
+ this.pauseElapsed();
300
311
  this.tableMounted = false;
301
312
  this.callbacks.closeStacked?.();
302
313
  }
@@ -311,6 +322,9 @@ export class MoaProgressWidget implements MoaProgressView {
311
322
  */
312
323
  startFanout(refs: ModelRef[], thinking: (ThinkingLevel | undefined)[] = [], maxConcurrency = refs.length): void {
313
324
  const startedAt = Date.now();
325
+ this.elapsedActiveMs = 0;
326
+ this.elapsedActiveSince = startedAt;
327
+ this.elapsedPauseDepth = 0;
314
328
  const activeCount = Number.isFinite(maxConcurrency) ? Math.max(1, Math.floor(maxConcurrency)) : refs.length;
315
329
  this.statuses = refs.map((ref, index) => ({
316
330
  ref,
@@ -341,6 +355,35 @@ export class MoaProgressWidget implements MoaProgressView {
341
355
  return this.active;
342
356
  }
343
357
 
358
+ /** Pause the run clock while a prompt, picker, or questionnaire waits on the user. */
359
+ pauseElapsed(now = Date.now()): void {
360
+ this.elapsedPauseDepth++;
361
+ if (this.elapsedPauseDepth !== 1 || this.elapsedActiveSince === undefined) return;
362
+ this.elapsedActiveMs += Math.max(0, now - this.elapsedActiveSince);
363
+ this.elapsedActiveSince = undefined;
364
+ }
365
+
366
+ /** Resume one pause owner; the clock restarts after the outermost wait ends. */
367
+ resumeElapsed(now = Date.now()): void {
368
+ if (this.elapsedPauseDepth === 0) return;
369
+ this.elapsedPauseDepth--;
370
+ if (this.elapsedPauseDepth === 0) this.elapsedActiveSince = now;
371
+ }
372
+
373
+ /** Run one user interaction without charging its wall time to the run total. */
374
+ async whileWaitingForUser<T>(task: () => Promise<T>): Promise<T> {
375
+ this.pauseElapsed();
376
+ try {
377
+ return await task();
378
+ } finally {
379
+ this.resumeElapsed();
380
+ }
381
+ }
382
+
383
+ totalElapsedMs(now = Date.now()): number {
384
+ return this.elapsedActiveMs + (this.elapsedActiveSince === undefined ? 0 : Math.max(0, now - this.elapsedActiveSince));
385
+ }
386
+
344
387
  phaseLabel(phase: MoaPhase): string {
345
388
  return this.callbacks.phaseLabels?.[phase] ?? phase;
346
389
  }
@@ -671,7 +714,7 @@ export class MoaProgressTableComponent implements Component {
671
714
  private readonly timer: ReturnType<typeof setInterval>;
672
715
  private readonly meters: RowMeter[] = [];
673
716
  private readonly previewCache = new Map<number, { revision: number; width: number; lines: string[] }>();
674
- private readonly createdAt = Date.now();
717
+ private readonly animationStartedAt = Date.now();
675
718
  private spinFrame = 0;
676
719
 
677
720
  constructor(tui: TUI, theme: ProgressTheme, view: MoaProgressView) {
@@ -760,7 +803,7 @@ export class MoaProgressTableComponent implements Component {
760
803
  const paint = (lit: boolean, text: string): string => {
761
804
  if (!lit) return th.fg("dim", text);
762
805
  return th.getFgAnsi
763
- ? shimmerString(text, now - this.createdAt, th as ShimmerTheme, "ltr", "normal", true)
806
+ ? shimmerString(text, now - this.animationStartedAt, th as ShimmerTheme, "ltr", "normal", true)
764
807
  : th.fg("text", text);
765
808
  };
766
809
  // Each boundary carries a two-row powerline chevron: the "\" half on the name
@@ -933,7 +976,7 @@ export class MoaProgressTableComponent implements Component {
933
976
  }
934
977
 
935
978
  const totalCost = rows.reduce((sum, r) => sum + (r.costUsd ?? 0), 0);
936
- const totals = `total ${formatCost(totalCost)} · ${formatElapsed(now - this.createdAt)}`;
979
+ const totals = `total ${formatCost(totalCost)} · ${formatElapsed(this.view.totalElapsedMs(now))}`;
937
980
  lines.push(border("─".repeat(innerWidth)));
938
981
  lines.push(row(`${th.fg("dim", TABLE_FOOTER)}${" ".repeat(Math.max(1, bodyWidth - visibleWidth(TABLE_FOOTER) - visibleWidth(totals)))}${th.fg("dim", totals)}`));
939
982
  lines.push(border("═".repeat(innerWidth)));