@underactive/pi-topping-moa-fusion 0.4.3 → 0.4.4

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,29 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.4.4]
6
+
7
+ ### Added
8
+
9
+ - Added the opt-in `minimalFanoutHeader` setting to hide the phase/model band and shimmer the active phase name in all progress tables.
10
+
11
+ ### Changed
12
+
13
+ - Verified compatibility with pi 1.1.0: no pi export this extension imports was removed or changed, and typecheck and the full test suite pass against 1.1.0.
14
+ - Updated all four pi development dependencies from 1.0.3 to 1.1.0.
15
+ - README and related comments clarify planning, verification, and subagent tool behavior.
16
+
17
+ ### Fixed
18
+
19
+ - Fan-out, synthesis, verification, debate, and implementation cost rows no longer overstate cost for tiered-price models (e.g. Claude Haiku 5.5). The displayed cost is now the sum of the per-response costs the child reports, each priced at its own tier. Registry rates over cumulative tokens are used only when the child reports no cost.
20
+ - On pi 1.0.4 and later, pi's built-in MCP `codemode`/`tool_search` tools stay available under a `--tools` allowlist, so a read-only child that loads all extensions (extension-registered providers) could reach write-capable MCP servers. Those children now run with `--no-mcp`. Support is probed on the pi executable the child spawns, not the locally installed pi package. Default and isolated children already exclude built-in MCP through `--no-extensions` and are unchanged. Failed or indeterminate version probes are not cached, so later children can retry the capability check.
21
+ - Cancelling an implementation while a tool is running no longer starts the verifier; the Implement row settles as cancelled. A provider error before any assistant reply still offers the retry. Hosts without an `agent_before_settle` boundary still dispatch verification for normal completed implementations. Known limit: on pi before 1.1, a cancel that arrives just as the implementation turn finishes can still start the verifier.
22
+ - `/mf-plan` proposers, synthesizer, and verifier, `/mf-opinion`, `/mf-debate`, and the `mf_plan_subagent` tool no longer fail to spawn a child for `litellm-auto/*` (or any extension-registered virtual model). Such models are registered via pi's `registerVirtualModel`, which keeps their provider out of `getRegisteredProviderIds()`, so the child was launched with a plain `--no-extensions`, stripped the autorouter extension, and died with `Model "litellm-auto/auto" not found`. Extension-registered virtual models are now detected by pi's virtual-model api id and load extensions in the child, matching native extension providers such as claude-bridge.
23
+ - Control characters cannot reach verifier or debate tasks, the observer transcript, or the verification findings popup. This prevents malformed child output from breaking a subprocess argument or controlling the terminal.
24
+ - Long newline-delimited child stdout events are no longer truncated before parsing.
25
+ - The minimal fan-out header keeps inactive phase headings dim.
26
+ - The selected conflict tab in the plan review overlay now uses a wider label with wrapping. Inactive tabs keep the short width on the same row.
27
+
5
28
  ## [0.4.3]
6
29
 
7
30
  ### Changed
package/README.md CHANGED
@@ -81,7 +81,7 @@ Fanning out a coding task and merging the resulting token streams does not work
81
81
  5. If the proposers substantively disagreed, a conflict-review overlay opens first: one tab per conflict, plain-language options, the synthesized pick tagged **(Recommended)**, and a **Chat about this** box for free-text feedback. Your resolutions are fed back for re-synthesis. Esc cancels the whole run.
82
82
  6. The plan opens in the same review overlay `exit_plan_mode` uses, where you can inspect any proposer's original plan, edit the synthesized one, or send the synthesizer feedback for a revised version (up to five rounds).
83
83
  7. On approval, the synthesizer makes one read-only pass to create a frozen observable checklist at `.pi/mf-plan/<slug>__criteria.md`, then plan mode exits and implementation begins in the current session using the implementer you already picked — there is **no** second picker (the single-model flow picks its implementing model up front when you choose **Single model**, and neither flow shows a model picker after approval — one appears only via `/mf-plan-implement`, or when an MoA run is resumed without a saved roster). If implementation fails (for example, auth expiration or provider error), an automatic recovery dialog prompts to retry, switch to a different model, or continue manually. You can also run `/mf-plan-implement` anytime to resume the approved plan.
84
- 8. Once the implementation turn settles, the **verification phase** runs when a verifier is configured: the project's own `check` / `lint` / `test` scripts run first, then a read-only `moa-verifier` subprocess judges the working tree against the frozen approved plan and scores every frozen criterion pass/fail/cannot-verify. The overall verdict is derived from those scores. If criteria are unavailable, it falls back to judging plan steps directly. If it finds gaps (or a check fails), the repair prompt shows a short Simplified Technical English summary, then the verifier summary, unmet criteria or gaps, and failing project checks, followed in the TUI by three choices in order: *View full findings*, *Send verifier findings to the implementer* (a bounded **repair round**, up to the [max verification repairs](#max-verification-repairs) setting — two by default), and *Accept implementation as-is*. *View full findings* opens the complete verifier report in a read-only, scrollable popup and, when closed, returns to the same three choices, so viewing never consumes a repair round. A repair round sends the findings back to the implementer and re-verifies. The raw verdict is saved next to the plan as `<slug>__verification.md`. Once the repair budget is spent, a handoff file is written as `<slug>__verification-handoff.md` and named in the exhaustion notification. A user-cancelled implementation is never verified.
84
+ 8. Once the implementation turn settles, the **verification phase** runs when a verifier is configured: the project's own `check` / `lint` / `test` scripts run first, then a read-only `moa-verifier` subprocess judges the working tree against the frozen approved plan and scores every frozen criterion pass/fail/cannot-verify. The overall verdict is derived from those scores. If criteria are unavailable, it falls back to judging plan steps directly. If it finds gaps (or a check fails), the repair prompt shows a short Simplified Technical English summary, then the verifier summary, unmet criteria or gaps, and failing project checks, followed in the TUI by three choices in order: *View full findings*, *Send verifier findings to the implementer* (a bounded **repair round**, up to the [max verification repairs](#max-verification-repairs) setting — two by default), and *Accept implementation as-is*. *View full findings* opens the complete verifier report in a read-only, scrollable popup and, when closed, returns to the same three choices, so viewing never consumes a repair round. A repair round sends the findings back to the implementer and re-verifies. The raw verdict is saved next to the plan as `<slug>__verification.md`. Once the repair budget is spent, a handoff file is written as `<slug>__verification-handoff.md` and named in the exhaustion notification. The verifier normally skips a user-cancelled implementation. On pi before 1.1, a cancel arriving as the turn settles can still start verification; on pi before 0.87, a mid-tool cancel may also be verified because the before-settle gate is unavailable.
85
85
 
86
86
  Before building the full verification task, MoA Fusion runs a cheap verifier preflight that asks for a one-word response. Auth, quota, model, and liveness problems therefore surface quickly with an actionable child error instead of after the full audit attempt.
87
87
 
@@ -122,7 +122,9 @@ Rows are grouped under `── Plan`, `── Synthesize`, `── Implement`, a
122
122
  - **CTX** — how much of that model's context window its latest turn is using.
123
123
  - **MONITOR** — an activity meter of *generated output* rate, independent of CTX: an agent can be deep into its context window and idle, or near empty and generating hard. Uses provider-reported token counts when available, otherwise word counts of the streamed deltas. Each row's meter is tinted with the thinking level that row is running under, using that level's native theme colour (`off` through `max`), so you can read a row's effort at a glance; the hue is tracked per row, so the same model can show different colours in different slots and it follows retries, model swaps, verifier fallback, and resumed runs. A row whose level is unknown falls back to the neutral accent colour, and idle cells and settled traces keep their usual dimming under whichever hue applies.
124
124
  - **ACTIVITY** — what the agent is doing, with its current tool call beneath, its tool name and argument highlighted with pi's own bold `toolTitle`/`accent` theme colours.
125
- - **TURNS** / **TOOLS** / **COST** / **TIME** — assistant turns completed, tool calls started, registry-rate model cost, and elapsed time. All freeze when the agent settles.
125
+ - **TURNS** / **TOOLS** / **COST** / **TIME** — assistant turns completed, tool calls started, model cost (the sum of the cost reported for each response; registry rates over the token totals only when no cost is reported), and elapsed time. All freeze when the agent settles.
126
+
127
+ With the [minimal fan-out header](#minimal-fan-out-header) setting on, the band is hidden and the active phase's heading (for example `── Plan`) shimmers instead; per-phase models then appear only in each row's MODEL cell.
126
128
 
127
129
  While an agent is working, its row also shows a dim, guttered preview of the latest four wrapped lines from the same transcript used by Observe. The preview follows the newest output automatically; short terminals reduce or drop preview lines after preserving tool activity, and narrow terminals omit previews entirely.
128
130
 
@@ -248,7 +250,12 @@ Seven agent definitions ship with the extension. Two belong to the single-model
248
250
 
249
251
  MoA never invokes `moa-explore` or `mf-plan` — each `moa-proposer` is a self-contained fusion of both roles, doing its own exploration so the proposals stay independent. The plan-only tools (`write_plan`, `exit_plan_mode`, and `mf_plan_subagent`) are active only while `/mf-plan` or single-model plan mode is on.
250
252
 
251
- Every planning subprocess is forced through a runtime `read,grep,find,ls` allowlist, even when an installed agent definition is stale, customized, or omits its `tools` field.
253
+ Every planning subprocess is forced through a runtime `read,grep,find,ls` allowlist, even when an installed agent definition is stale, customized, or omits its `tools` field. Pi's built-in MCP is also kept out of every child: `--no-extensions` excludes it, and children that must load all extensions (extension-registered providers) run with `--no-mcp` on pi 1.0.4 and later, where MCP tools would otherwise stay available under the allowlist.
254
+
255
+ **Agentic provider bridges.** Some pi providers are not plain chat-completion APIs but bridges to full coding agents with their *own* local edit/shell tools (e.g. pi-cursor-bridge, whose models run Cursor agents in the repo cwd). Pi's tool allowlist cannot restrain those agent-side tools, so plan mode adds two more layers:
256
+
257
+ - **Read-only env handshake** — every planning subprocess is spawned with `PI_CURSOR_FORCE_MODE=plan`, and the parent session sets it while plan mode is active. cursor-bridge maps this to Cursor's native read-only *plan* mode (SDK `mode: "plan"`; CLI `--mode plan` without `--force`).
258
+ - **Mutation tripwire** — `git status --porcelain` is snapshotted before subagents launch and re-checked after every phase. If the working tree changed while planning agents ran, a warning lists the touched files so rogue edits are never silently absorbed.
252
259
 
253
260
  ### Choosing their models
254
261
 
@@ -266,6 +273,8 @@ thinking: off
266
273
 
267
274
  `/mf-plan-settings` writes these two fields for you, but editing the file by hand works equally well — the overlay reads its current values back. An unrecognized `thinking:` value is ignored rather than passed to the subprocess.
268
275
 
276
+ **First run.** Until the setup overlay has been saved once, `/mf-plan` opens it — set up agents and rosters before planning — instead of the plan prompt. Only interactive TUI sessions are gated; headless plan mode is unaffected.
277
+
269
278
  #### Agent rosters
270
279
 
271
280
  The MoA roles (proposers, synthesizer, implementer, verifier) are no longer assigned one-by-one in `/mf-plan-settings`. Instead the overlay's **agent rosters** row manages named rosters — reusable sets that assign a model + thinking level to every MoA role at once (2–5 proposers plus the three required roles; a roster must be complete to save). Each run, the picker's **Load Roster** row applies a roster to every slot wholesale — a 2-proposer roster also clears stale picks from slots 3–5 — and slots whose model is no longer callable are skipped with a warning while the rest load normally.
@@ -311,12 +320,17 @@ The **max verification repairs** row in `/mf-plan-settings` controls how many au
311
320
 
312
321
  The default is **2**. Set **0** to disable automatic repair offers — verification still runs, but gaps go straight to an exhaustion notification. When repairs are exhausted or disabled, MoA Fusion writes `.pi/mf-plan/<slug>__verification-handoff.md` with each criterion's pass/fail status, the full frozen criteria checklist, gaps, and failing project checks so you can continue the fix/verification loop manually (for example via `/mf-plan-implement`). The exhaustion notification includes the handoff file path.
313
322
 
314
- **First run.** Until the setup overlay has been saved once, `/mf-plan` opens it — set up agents and rosters before planning — instead of the plan prompt. Only interactive TUI sessions are gated; headless plan mode is unaffected.
323
+ #### Minimal fan-out header
315
324
 
316
- **Agentic provider bridges.** Some pi providers are not plain chat-completion APIs but bridges to full coding agents with their *own* local edit/shell tools (e.g. pi-cursor-bridge, whose models run Cursor agents in the repo cwd). Pi's tool allowlist cannot restrain those agent-side tools, so plan mode adds two more layers:
325
+ The **minimal fan-out header** row in `/mf-plan-settings` hides the phase/model band and makes the active phase name shimmer. Press Space on the row to turn it on or off. The option applies to the plan, resumed-implementation, opinion, and debate tables. On truecolor themes, the sweep covers the phase name alone — the `── ` prefix and the trailing rule stay flat; other themes show a flat highlight on the name.
317
326
 
318
- - **Read-only env handshake** — every planning subprocess is spawned with `PI_CURSOR_FORCE_MODE=plan`, and the parent session sets it while plan mode is active. cursor-bridge maps this to Cursor's native read-only *plan* mode (SDK `mode: "plan"`; CLI `--mode plan` without `--force`).
319
- - **Mutation tripwire** — `git status --porcelain` is snapshotted before subagents launch and re-checked after every phase. If the working tree changed while planning agents ran, a warning lists the touched files so rogue edits are never silently absorbed.
327
+ The setting persists in `~/.pi/agent/mf-plan/settings.json`:
328
+
329
+ ```json
330
+ { "minimalFanoutHeader": false }
331
+ ```
332
+
333
+ The default is **off**. A missing or non-boolean value also means off. Each row still shows its model in the MODEL cell. A resumed implementation table has no Plan or Synthesize rows, so it does not show those model names. The setting is read once when a run starts, so edits apply to the next run, not one already in progress.
320
334
 
321
335
  ### Parallel execution
322
336
 
@@ -356,6 +370,7 @@ src/
356
370
  ├── activityMeter.ts # Dependency-free output-rate meter
357
371
  ├── shared/
358
372
  │ ├── modelRefs.ts # Model refs, thinking levels, display labels
373
+ │ ├── controlChars.ts # Control-character stripping for argv and display
359
374
  │ └── functionKeys.ts # F-key press matching (incl. Kitty encoding)
360
375
  ├── config/
361
376
  │ ├── settings.ts # Persistent MoA settings
@@ -373,6 +388,7 @@ src/
373
388
  │ ├── activityTracking.ts # Streaming output/activity assembly
374
389
  │ ├── results.ts # Runner result classification/output helpers
375
390
  │ ├── cancelRun.ts # Per-agent cancellation bookkeeping
391
+ │ ├── piCapabilities.ts # Flag-support probes for the spawned pi executable
376
392
  │ └── mutationTripwire.ts # Working-tree mutation detection
377
393
  ├── opinion/
378
394
  │ ├── runOpinion.ts # Interactive opinion command orchestration
@@ -396,11 +412,13 @@ src/
396
412
  │ ├── planFile.ts # Slugs, plan files, proposal staging
397
413
  │ ├── instructions.ts # Injected 5-phase plan-mode instructions
398
414
  │ ├── askUserQuestion.ts # rpiv questionnaire detection and blocked-state tracking
415
+ │ ├── contextEdits.ts # Plan-mode context omission and verification-pending helpers
399
416
  │ └── tools/
400
417
  │ ├── shared.ts
401
418
  │ ├── enterPlanMode.ts # Interactive entry flow, ESC listener, agent tool
402
419
  │ ├── writePlan.ts
403
420
  │ ├── exitPlanMode.ts
421
+ │ ├── structuredResults.ts # Structured result schemas for plan-mode tools
404
422
  │ └── mfPlanSubagent.ts
405
423
  ├── moa/
406
424
  │ ├── orchestration.ts # Outer phase sequencer and run cleanup
@@ -418,6 +436,7 @@ src/
418
436
  │ ├── conflictContract.ts # Conflict-surfacing task-text contract
419
437
  │ ├── contextContract.ts # Context-subsection (auditable reasoning) contract
420
438
  │ ├── planInfo.ts # Persisted MoA metadata validation
439
+ │ ├── panelDependence.ts # Proposer model-dependence and diversity summaries
421
440
  │ ├── verdicts.ts
422
441
  │ └── planlessRetry.ts
423
442
  └── ui/
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-moa-fusion",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
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": "1.0.3",
54
- "@earendil-works/pi-ai": "1.0.3",
55
- "@earendil-works/pi-coding-agent": "1.0.3",
56
- "@earendil-works/pi-tui": "1.0.3",
53
+ "@earendil-works/pi-agent-core": "1.1.0",
54
+ "@earendil-works/pi-ai": "1.1.0",
55
+ "@earendil-works/pi-coding-agent": "1.1.0",
56
+ "@earendil-works/pi-tui": "1.1.0",
57
57
  "@types/node": "^24.0.0",
58
58
  "typebox": "^1.3.3",
59
59
  "typescript": "^5.8.0"
@@ -22,7 +22,7 @@ export interface PlanNameOptions {
22
22
  signal?: AbortSignal;
23
23
  }
24
24
 
25
- /** Call the active model to summarize a plan prompt into a 4-word slug. */
25
+ /** Summarize a plan prompt into a 4-word slug with the cheap/fast model (the active model when none is set), or return a random phrase when summary names are off. */
26
26
  export async function summarizePlanPromptName(ctx: ExtensionContext, prompt: string, options?: PlanNameOptions): Promise<string> {
27
27
  const fallback = () => slugifyPlanName(prompt);
28
28
 
@@ -73,6 +73,8 @@ export interface MoaConfig {
73
73
  autoResolveConflicts: boolean;
74
74
  /** Use LLM-summarized plan names (true) or random adjective-adjective-noun phrases (false). */
75
75
  useSummaryName: boolean;
76
+ /** Hide the phase/model band above the MoA table and shimmer the active phase name instead. Defaults to false. */
77
+ minimalFanoutHeader: boolean;
76
78
  /**
77
79
  * Whether the user has been through the setup overlay at least once. Gates the
78
80
  * first `/mf-plan` into setup. Kept here rather than inferred from the agent
@@ -99,6 +101,7 @@ function emptyConfig(): MoaConfig {
99
101
  rosters: [],
100
102
  autoResolveConflicts: false,
101
103
  useSummaryName: true,
104
+ minimalFanoutHeader: false,
102
105
  agentDefaultsConfigured: false,
103
106
  };
104
107
  }
@@ -147,10 +150,12 @@ function parseSettingsFile(path: string): MoaConfig {
147
150
  ? parsed.useSummaryName
148
151
  : true;
149
152
 
153
+ const minimalFanoutHeader = parsed.minimalFanoutHeader === true;
154
+
150
155
  const agentDefaultsConfigured = parsed.agentDefaultsConfigured === true;
151
156
  const rosters = parseRosters(parsed.rosters);
152
157
 
153
- return { mode, proposers, opinionModels, debateModels, debateRounds, maxConcurrentAgents, maxVerificationRepairs, synthesizer, implementer, verifier, cheap, thinkingOverrides, rosters, autoResolveConflicts, useSummaryName, agentDefaultsConfigured };
158
+ return { mode, proposers, opinionModels, debateModels, debateRounds, maxConcurrentAgents, maxVerificationRepairs, synthesizer, implementer, verifier, cheap, thinkingOverrides, rosters, autoResolveConflicts, useSummaryName, minimalFanoutHeader, agentDefaultsConfigured };
154
159
  } catch (err) {
155
160
  console.error("mf-plan: failed to parse settings, using defaults:", err);
156
161
  return emptyConfig();
@@ -1,4 +1,5 @@
1
1
  import { buildRetryCorrection } from "../moa/planlessRetry.ts";
2
+ import { stripControlCharacters } from "../shared/controlChars.ts";
2
3
 
3
4
  export const DEBATE_TASK_PREAMBLE =
4
5
  "You are a read-only DEBATER in a multi-round debate between several models. Your output is shown verbatim to the user and reaches your peer debaters — there is no judge, and no agent will merge or verify anything — so argue to convince your peers. You may keep or change your stance in any round. Do not implement anything, edit files, or run commands, builds, or tests; read-only tools are expected and never a blocker. You are running headless: resolve open questions with stated assumptions rather than asking the user or waiting. The required output sections (including the `## Position` argument and the `## Stance` declaration with its `**Stance:**` value) are spelled out in the agent instructions.";
@@ -38,7 +39,7 @@ export function debaterLabel(index: number): string {
38
39
  const MAX_PRIOR_CHARS = 16_000;
39
40
 
40
41
  function quotePrior(text: string): string {
41
- const trimmed = text.trim();
42
+ const trimmed = stripControlCharacters(text).trim();
42
43
  const prior = trimmed.length > MAX_PRIOR_CHARS
43
44
  ? `${trimmed.slice(0, MAX_PRIOR_CHARS)}\n[prior position truncated]`
44
45
  : trimmed;
@@ -93,6 +93,7 @@ export async function runInteractiveDebate(
93
93
  title: "MoA Debate",
94
94
  phaseLabels: { Plan: "Debate" },
95
95
  fanoutWorkingText: "round 1 · forming positions",
96
+ minimalFanoutHeader: settings.minimalFanoutHeader,
96
97
  },
97
98
  slug,
98
99
  );
@@ -8,8 +8,27 @@ import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
8
8
  import type { UsageStats } from "../runtime/results.ts";
9
9
  import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
10
10
 
11
+ /**
12
+ * pi's internal `api` id for extension-registered virtual models (the autorouter's
13
+ * `litellm-auto/*`). pi does not export the constant, so mirror the value the
14
+ * autorouter extension pins in its own catalog.ts.
15
+ */
16
+ const VIRTUAL_MODEL_API = "pi-virtual";
17
+
18
+ /**
19
+ * Whether routing this model in a child subprocess requires loading the extension that
20
+ * registered its provider. True for native extension providers (claude-bridge) — surfaced by
21
+ * getRegisteredProviderIds() — and for virtual models registered via registerVirtualModel
22
+ * (litellm-auto/*). A virtual model's provider never reaches getRegisteredProviderIds():
23
+ * registerVirtualModel keeps it in a separate virtual-model map, so a child spawned with a plain
24
+ * --no-extensions cannot resolve `litellm-auto/auto` and dies with "Model not found". External
25
+ * extensions can register virtual models too, so this keys off pi's virtual-model api id rather
26
+ * than a hardcoded provider name.
27
+ */
11
28
  export function modelUsesExtensionRegisteredProvider(ctx: ExtensionContext, ref: ModelRef): boolean {
12
- return ctx.modelRegistry.getRegisteredProviderIds().includes(ref.provider);
29
+ if (ctx.modelRegistry.getRegisteredProviderIds().includes(ref.provider)) return true;
30
+ // Only an extension can register a virtual (api === "pi-virtual") model, so one mandates loadExtensions in the child.
31
+ return ctx.modelRegistry.find(ref.provider, ref.id)?.api === VIRTUAL_MODEL_API;
13
32
  }
14
33
 
15
34
  export function cursorBridgeExtensionPath(): string | undefined {
@@ -44,8 +63,15 @@ export function resolveContextWindow(ctx: ExtensionContext, ref: ModelRef): numb
44
63
  return ctx.modelRegistry.find(ref.provider, ref.id)?.contextWindow;
45
64
  }
46
65
 
47
- /** Calculate cumulative cost from the registry rates and the runner's token totals. */
66
+ /**
67
+ * Cost for a role's usage. Prefers the per-response costs the child reported
68
+ * (already summed in `usage.cost`), because each response is priced at its own
69
+ * tier. Falls back to the registry rates over cumulative token totals only
70
+ * when the child reported no cost; for tiered models that fallback prices the
71
+ * summed tokens as one request and can overstate the cost.
72
+ */
48
73
  export function resolveModelCost(ctx: ExtensionContext, ref: ModelRef, usage: UsageStats): number | undefined {
74
+ if (typeof usage.cost === "number" && Number.isFinite(usage.cost) && usage.cost > 0) return usage.cost;
49
75
  const model = ctx.modelRegistry.find(ref.provider, ref.id);
50
76
  if (!model) return undefined;
51
77
  const cost = calculateCost(model, {
@@ -31,6 +31,7 @@ export async function runMoaOrchestration(
31
31
  session.closeOverlay?.();
32
32
  runContext.observeSession?.closeOverlay?.();
33
33
  },
34
+ minimalFanoutHeader: loadMoaConfig().minimalFanoutHeader,
34
35
  }, host.getPlanRepoSlug());
35
36
  host.setRunningProgressWidget(widget);
36
37
  widget.setPhaseModels({
@@ -4,7 +4,7 @@ import { stripControlCharacters } from "../shared/controlChars.ts";
4
4
  * Recovery for planning agents that exit cleanly without delivering a plan.
5
5
  *
6
6
  * Bridged agentic providers can override the moa-proposer/moa-synthesizer
7
- * prompt with their own interactive harness, producing two planless shapes:
7
+ * prompt with their own interactive harness, producing three planless shapes:
8
8
  *
9
9
  * - Question stops: Cursor's plan mode researches and then stops to ask the
10
10
  * user a clarifying question before writing the plan. In a headless
@@ -18,8 +18,8 @@ import { stripControlCharacters } from "../shared/controlChars.ts";
18
18
  * prompt never sees the required output sections, explores interactively,
19
19
  * and exits with questions or prose instead of a plan.
20
20
  *
21
- * These helpers detect both shapes so the orchestrator can re-run the agent
22
- * once with an explicit instruction to answer its own questions / emit the
21
+ * These helpers detect all three shapes so the orchestrator can re-run the
22
+ * agent once with an explicit instruction to answer its own questions / emit the
23
23
  * plan, and carry the read-only planning contract inside the task text —
24
24
  * the only channel guaranteed to survive a bridge's prompt override.
25
25
  */
@@ -12,7 +12,6 @@
12
12
  */
13
13
 
14
14
  import { parseConflicts } from "./conflicts.ts";
15
- import { buildRetryCorrection } from "./planlessRetry.ts";
16
15
 
17
16
  const VERDICTS_SECTION_RE = /^##\s*Proposer Verdicts\s*$/m;
18
17
  const VERDICT_BULLET_RE = /^[-*]\s+\*\*(Proposer \d+):?\*\*:?\s*(.+)$/gm;
@@ -75,7 +74,3 @@ export function missingVerdictSlots(verdictsMarkdown: string | null, labels: str
75
74
  export function buildVerdictRetryHeader(missing: string[]): string {
76
75
  return `IMPORTANT: Your previous output (quoted below) is rejected because it does not prove you evaluated every proposal: no verdict was found for ${missing.join(", ")}. Re-read those proposals from your input now and re-emit the COMPLETE plan including a \`## Proposer Verdicts\` section with one \`- **Proposer N:** adopted | partial | rejected — reason\` bullet for every proposal you received. Do not drop or alter the rest of the plan beyond what re-evaluating those proposals requires.`;
77
76
  }
78
-
79
- export function buildVerdictRetryTask(originalTask: string, previousOutput: string, missing: string[]): string {
80
- return [originalTask, "", "---", "", buildRetryCorrection(buildVerdictRetryHeader(missing), previousOutput)].join("\n");
81
- }
@@ -35,6 +35,7 @@ import { CancelRun, type CancelSession } from "../runtime/cancelRun.ts";
35
35
  import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
36
36
  import { getFinalOutput, getResultOutput, isFailedResult, type SingleResult } from "../runtime/results.ts";
37
37
  import { runSingleAgent } from "../runtime/runner.ts";
38
+ import { stripControlCharacters } from "../shared/controlChars.ts";
38
39
  import { modelRefLabel, TRIGGER_TURN } from "../shared/modelRefs.ts";
39
40
  import { activityLoopCount } from "../ui/agentStatus.ts";
40
41
  import { showVerificationFindings } from "../ui/verificationFindingsOverlay.ts";
@@ -170,26 +171,30 @@ export function buildVerifierTask(input: {
170
171
  criteria?: VerificationCriterion[];
171
172
  }): string {
172
173
  const { plan, implementerReport, diff, scriptResults, criteria } = input;
173
- const reportSection = implementerReport && implementerReport.trim()
174
- ? implementerReport.trim()
175
- : "(The implementer produced no final summary message.)";
174
+ const safePlan = stripControlCharacters(plan);
175
+ const safeCriteria = criteria?.map((criterion) => ({
176
+ id: stripControlCharacters(criterion.id),
177
+ text: stripControlCharacters(criterion.text),
178
+ }));
179
+ const report = implementerReport ? stripControlCharacters(implementerReport).trim() : "";
180
+ const reportSection = report || "(The implementer produced no final summary message.)";
176
181
  const diffSection = diff && diff.trim()
177
182
  ? diff.trim()
178
183
  : "(No git diff available — not a git repository, or no changes detected.)";
179
- const outputContract = criteria?.length
180
- ? `## Required output format\n\nEmit exactly these sections at the end of your reply:\n\n## Criteria Verdicts\n${criteria.map((criterion) => `- **${criterion.id}:** pass | fail | cannot-verify — <evidence: file:line or what is missing>`).join("\n")}\n(one bullet per supplied criterion; no IDs skipped)\n\n## Deviations\n- <anything implemented differently> (or "None.")\n\n## Verification Result\n**Verdict:** complete | partial | incomplete | cannot-verify\n**Summary:** <one paragraph>\n\n### Gaps\n- <unmet criterion> — <evidence>\n\nUse complete only when every criterion is pass and every provided check passed. Prefer cannot-verify over a guessed pass.`
184
+ const outputContract = safeCriteria?.length
185
+ ? `## Required output format\n\nEmit exactly these sections at the end of your reply:\n\n## Criteria Verdicts\n${safeCriteria.map((criterion) => `- **${criterion.id}:** pass | fail | cannot-verify — <evidence: file:line or what is missing>`).join("\n")}\n(one bullet per supplied criterion; no IDs skipped)\n\n## Deviations\n- <anything implemented differently> (or "None.")\n\n## Verification Result\n**Verdict:** complete | partial | incomplete | cannot-verify\n**Summary:** <one paragraph>\n\n### Gaps\n- <unmet criterion> — <evidence>\n\nUse complete only when every criterion is pass and every provided check passed. Prefer cannot-verify over a guessed pass.`
181
186
  : VERIFIER_OUTPUT_CONTRACT;
182
187
  const gateSection = scriptResults.length > 0
183
188
  ? scriptResults
184
- .map((r) => `- ${r.command} → ${r.status.toUpperCase()} (exit ${r.exitCode})\n\`\`\`\n${r.relevantOutput}\n\`\`\``)
189
+ .map((r) => `- ${r.command} → ${r.status.toUpperCase()} (exit ${r.exitCode})\n\`\`\`\n${stripControlCharacters(r.relevantOutput)}\n\`\`\``)
185
190
  .join("\n\n")
186
191
  : "(No check/lint/test scripts were discovered in package.json.)";
187
192
 
188
193
  return [
189
194
  VERIFIER_TASK_PREAMBLE,
190
195
  "---",
191
- `## Approved plan (source of truth)\nVerify the working tree against every step of this plan:\n\n${plan}`,
192
- criteria?.length ? `## Verification criteria (authoritative pass/fail checklist)\n${criteria.map((criterion) => `- **${criterion.id}:** ${criterion.text}`).join("\n")}\n\nWhen criteria are supplied, emit ## Criteria Verdicts with one \`- **C<n>:** pass | fail | cannot-verify — evidence\` bullet for every criterion and no skipped ids, then ## Deviations and the required ## Verification Result. Use complete only when every criterion is pass; use cannot-verify rather than guessing.` : "",
196
+ `## Approved plan (source of truth)\nVerify the working tree against every step of this plan:\n\n${safePlan}`,
197
+ safeCriteria?.length ? `## Verification criteria (authoritative pass/fail checklist)\n${safeCriteria.map((criterion) => `- **${criterion.id}:** ${criterion.text}`).join("\n")}\n\nWhen criteria are supplied, emit ## Criteria Verdicts with one \`- **C<n>:** pass | fail | cannot-verify — evidence\` bullet for every criterion and no skipped ids, then ## Deviations and the required ## Verification Result. Use complete only when every criterion is pass; use cannot-verify rather than guessing.` : "",
193
198
  "---",
194
199
  `## Implementer's self-report (UNTRUSTED CLAIM — not evidence)\n${reportSection}`,
195
200
  "---",
@@ -4,6 +4,7 @@ import type { AgentConfig } from "../agents/discovery.ts";
4
4
  import { CancelRun, type CancelSession } from "../runtime/cancelRun.ts";
5
5
  import { getFinalOutput, isFailedResult } from "../runtime/results.ts";
6
6
  import { runSingleAgent } from "../runtime/runner.ts";
7
+ import { stripControlCharacters } from "../shared/controlChars.ts";
7
8
  import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
8
9
  import { activityLoopCount } from "../ui/agentStatus.ts";
9
10
  import type { ObserveSession } from "../ui/observeOverlay.ts";
@@ -26,7 +27,7 @@ export function buildCriteriaTask(plan: string): string {
26
27
  return [
27
28
  CRITERIA_TASK_PREAMBLE,
28
29
  "---",
29
- `## Approved plan (frozen)\n${plan}`,
30
+ `## Approved plan (frozen)\n${stripControlCharacters(plan)}`,
30
31
  "---",
31
32
  `Your output must contain exactly one ${VERIFICATION_CRITERIA_HEADING} section and no prose outside it. Emit 3–${MAX_VERIFICATION_CRITERIA} bullets in this exact form:\n- **C<n>:** <observable binary condition> — <how to check: file, symbol, grep, expected state>\n\nEvery criterion must be checkable by reading the repository only; do not require execution. Cover at least one criterion per plan step in plan order, every named file or call site, and implied regression/scope constraints (such as preserving an existing export or avoiding unrelated files). Never reference proposer labels or model names.`,
32
33
  ].join("\n\n");
@@ -99,6 +99,7 @@ export async function runInteractiveOpinion(
99
99
  title: "MoA Opinion",
100
100
  phaseLabels: { Plan: "Opinion" },
101
101
  fanoutWorkingText: "analyzing & answering",
102
+ minimalFanoutHeader: settings.minimalFanoutHeader,
102
103
  },
103
104
  slug,
104
105
  );
@@ -147,6 +147,10 @@ export function createPlanModeController(pi: ExtensionAPI) {
147
147
  let implementationContextTokens: number | undefined;
148
148
  let implementationTurns = 0;
149
149
  let implementationToolCalls = 0;
150
+ // Older pi hosts have no before-settle boundary; enforce its veto only after
151
+ // this host has exposed the boundary.
152
+ let settleBoundarySeen = false;
153
+ let verificationPermittedAtSettle = false;
150
154
  let providerEnvBeforePlanMode: Record<string, string | undefined> | undefined;
151
155
  const persistence = new PlanModeStatePersistence(pi);
152
156
  // Tracks whether rpiv's ask_user_question (or whichever extension registers
@@ -181,6 +185,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
181
185
  implementationContextTokens = undefined;
182
186
  implementationTurns = 0;
183
187
  implementationToolCalls = 0;
188
+ verificationPermittedAtSettle = false;
184
189
  };
185
190
  const resetImplementationState = (): void => {
186
191
  implementationPending = false;
@@ -471,7 +476,9 @@ export function createPlanModeController(pi: ExtensionAPI) {
471
476
  // adopted widget, so this only fires when none exists; single-model
472
477
  // exit_plan_mode passes no ctx and has no MoA info, so it is skipped.
473
478
  if (!activeProgressWidget && ctx && activeRunMoaInfo && implementationHandoff?.model) {
474
- const widget = new MoaProgressWidget(ctx, (ref) => resolveContextWindow(ctx, ref), {}, planRepoSlug);
479
+ const widget = new MoaProgressWidget(ctx, (ref) => resolveContextWindow(ctx, ref), {
480
+ minimalFanoutHeader: loadMoaConfig().minimalFanoutHeader,
481
+ }, planRepoSlug);
475
482
  widget.setPhaseModels({
476
483
  Plan: activeRunMoaInfo.proposers,
477
484
  Synthesize: activeRunMoaInfo.synthesizer,
@@ -570,8 +577,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
570
577
  // lets the meter reset rather than metering the correction as output.
571
578
  const output = implementationOutputTracker?.snapshot();
572
579
  if (output && activeProgressWidget) activeProgressWidget.updateRoleOutput("Implement", output.tokens, output.revision);
580
+ implementationUsage ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 };
573
581
  if (msg.usage) {
574
- implementationUsage ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 };
575
582
  implementationUsage.input += nonnegative(msg.usage.input);
576
583
  implementationUsage.output += nonnegative(msg.usage.output);
577
584
  implementationUsage.cacheRead += nonnegative(msg.usage.cacheRead);
@@ -589,7 +596,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
589
596
  implementationContextTokens,
590
597
  implementationTurns,
591
598
  implementationToolCalls,
592
- resolveModelCost(ctx, implementationHandoff.model, implementationUsage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 }),
599
+ resolveModelCost(ctx, implementationHandoff.model, implementationUsage),
593
600
  );
594
601
  }
595
602
  implementationPartial.clear();
@@ -601,7 +608,25 @@ export function createPlanModeController(pi: ExtensionAPI) {
601
608
  }
602
609
  },
603
610
  onAgentBeforeSettle: (event: AgentBeforeSettleEvent, ctx: ExtensionContext) => {
611
+ settleBoundarySeen = true;
612
+ verificationPermittedAtSettle = false;
604
613
  try {
614
+ const stopReason = lastImplementationStopReason;
615
+ // KEEP IN LOCKSTEP with onAgentSettled's verification branch below.
616
+ // exit_plan_mode marks implementation pending before the follow-up has
617
+ // run, so implementationTurns distinguishes that planning boundary.
618
+ const verificationWillDispatch = implementationPending
619
+ && implementationTurns > 0
620
+ && event.context.pendingMessages.length === 0
621
+ && event.outcome !== "aborted"
622
+ && event.outcome !== "error"
623
+ && !implementationTurnFailed(stopReason)
624
+ && stopReason !== "aborted"
625
+ && stopReason !== "toolUse"
626
+ && implementationHandoff !== undefined
627
+ && (implementationHandoff.verifier ?? loadMoaConfig().verifier) !== undefined;
628
+ verificationPermittedAtSettle = verificationWillDispatch;
629
+
605
630
  const branch = readBranchEntries(ctx);
606
631
  const persistedOmissions = new Set(
607
632
  branch
@@ -625,19 +650,6 @@ export function createPlanModeController(pi: ExtensionAPI) {
625
650
  drafts.push(...collectOmissionDrafts(branch, isVerificationPendingEntry, omittedEntryIds));
626
651
  }
627
652
 
628
- const stopReason = lastImplementationStopReason;
629
- // KEEP IN LOCKSTEP with onAgentSettled's verification branch below.
630
- // exit_plan_mode marks implementation pending before the follow-up has
631
- // run, so implementationTurns distinguishes that planning boundary.
632
- const verificationWillDispatch = implementationPending
633
- && implementationTurns > 0
634
- && event.context.pendingMessages.length === 0
635
- && event.outcome !== "aborted"
636
- && event.outcome !== "error"
637
- && !implementationTurnFailed(stopReason)
638
- && stopReason !== "aborted"
639
- && implementationHandoff !== undefined
640
- && (implementationHandoff.verifier ?? loadMoaConfig().verifier) !== undefined;
641
653
  if (verificationWillDispatch) {
642
654
  drafts.push({
643
655
  type: "custom_message",
@@ -657,10 +669,15 @@ export function createPlanModeController(pi: ExtensionAPI) {
657
669
  return undefined;
658
670
  }
659
671
  },
660
- onAgentSettled: (_event: AgentSettledEvent, ctx: ExtensionContext) => {
672
+ onAgentSettled: (event: AgentSettledEvent, ctx: ExtensionContext) => {
661
673
  updateStatus(ctx);
674
+ const verificationPermitted = verificationPermittedAtSettle;
675
+ verificationPermittedAtSettle = false;
662
676
  if (!implementationPending) return;
663
677
  implementationPending = false;
678
+ // pi >= 1.1 reports a cancel that never produced an aborted assistant
679
+ // message (e.g. cancelled mid-tool); older pi omits the field.
680
+ const runAborted = event.aborted === true;
664
681
  const stopReason = lastImplementationStopReason;
665
682
  const report = lastImplementationReport;
666
683
  lastImplementationStopReason = undefined;
@@ -669,9 +686,9 @@ export function createPlanModeController(pi: ExtensionAPI) {
669
686
  // Settle the Implement row before any retry/verify routing. Aborted
670
687
  // runs stop the table outright (no verification follows).
671
688
  if (activeProgressWidget) {
672
- if (implementationTurnFailed(stopReason)) {
689
+ if (implementationTurnFailed(stopReason) && !runAborted) {
673
690
  activeProgressWidget.settleRoleRow("Implement", "error");
674
- } else if (stopReason === "aborted") {
691
+ } else if (stopReason === "aborted" || runAborted) {
675
692
  activeProgressWidget.settleRoleRow("Implement", "cancelled");
676
693
  stopActiveProgressWidget();
677
694
  } else {
@@ -694,15 +711,15 @@ export function createPlanModeController(pi: ExtensionAPI) {
694
711
  // ctx is stale (replaced/reloaded); nothing left to notify against.
695
712
  }
696
713
  };
697
- if (implementationTurnFailed(stopReason) && implementationHandoff) {
714
+ if (implementationTurnFailed(stopReason) && implementationHandoff && !runAborted) {
698
715
  void runImplementationRetryFlow(ctx, moaRunHost, implementationHandoff)
699
716
  .catch(reportFlowFailure("Implementation retry"));
700
717
  return;
701
718
  }
702
- // A user-cancelled implementation must NOT be verified. implementationTurnFailed
703
- // returns false for "aborted" (so the retry flow above is skipped), so this
704
- // explicit guard is required before the verification branch.
705
- if (stopReason === "aborted") return;
719
+ // Older hosts omit the settled aborted flag for mid-tool cancellation, leaving
720
+ // toolUse as the last stop reason.
721
+ if (stopReason === "aborted" || stopReason === "toolUse" || runAborted) return;
722
+ if (settleBoundarySeen && !verificationPermitted) return;
706
723
  if (!implementationHandoff) return;
707
724
  // KEEP IN LOCKSTEP with verificationWillDispatch in onAgentBeforeSettle.
708
725
  const verifier = implementationHandoff.verifier ?? loadMoaConfig().verifier;
@@ -144,8 +144,9 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
144
144
  const overrideFor = (agentName: string) => (agentName === "mf-plan" ? planOverride : undefined);
145
145
 
146
146
  // An agent whose effective model belongs to an extension-registered
147
- // provider (claude-bridge, cursor-bridge) cannot resolve in a child that
148
- // spawns with --no-extensions. Same opt-in the MoA fan-out already does;
147
+ // provider (claude-bridge, cursor-bridge) or is an extension-registered virtual model
148
+ // (litellm-auto/*) cannot resolve in a child that spawns with --no-extensions.
149
+ // Same opt-in the MoA fan-out already does;
149
150
  // built-in and bare ids fall through to no extension loading.
150
151
  const agentExtensionOptions = (agentName: string): { loadExtensions?: boolean; extensionPath?: string } => {
151
152
  const model = overrideFor(agentName)?.model ?? agents.find((agent) => agent.name === agentName)?.model;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Capability probing for the pi executable a child process will actually run.
3
+ * The local `@earendil-works/pi-coding-agent` VERSION export describes the
4
+ * package this extension was built against, not the host (or `pi` on PATH)
5
+ * that `getPiInvocation()` spawns, so flag support is probed on the executable.
6
+ */
7
+
8
+ import { execFile } from "node:child_process";
9
+
10
+ export interface PiInvocation {
11
+ command: string;
12
+ args: string[];
13
+ }
14
+
15
+ /** `--no-mcp` first ships in pi 1.0.4; 1.0.3 rejects it as an unknown option. */
16
+ const NO_MCP_MIN_VERSION = [1, 0, 4] as const;
17
+ const VERSION_PROBE_TIMEOUT_MS = 10_000;
18
+
19
+ export function parsePiVersion(output: string): [number, number, number] | undefined {
20
+ const match = /(\d+)\.(\d+)\.(\d+)/.exec(output);
21
+ if (!match) return undefined;
22
+ return [Number(match[1]), Number(match[2]), Number(match[3])];
23
+ }
24
+
25
+ export function versionAtLeast(version: readonly number[], minimum: readonly number[]): boolean {
26
+ for (let i = 0; i < minimum.length; i++) {
27
+ const diff = (version[i] ?? 0) - minimum[i];
28
+ if (diff !== 0) return diff > 0;
29
+ }
30
+ return true;
31
+ }
32
+
33
+ export function versionSupportsNoMcp(output: string): boolean {
34
+ const version = parsePiVersion(output);
35
+ return version !== undefined && versionAtLeast(version, NO_MCP_MIN_VERSION);
36
+ }
37
+
38
+ const noMcpSupport = new Map<string, Promise<boolean>>();
39
+
40
+ function probeVersion(invocation: PiInvocation): Promise<string> {
41
+ return new Promise((resolve) => {
42
+ try {
43
+ execFile(
44
+ invocation.command,
45
+ [...invocation.args, "--version"],
46
+ { timeout: VERSION_PROBE_TIMEOUT_MS, windowsHide: true, encoding: "utf-8" },
47
+ (error, stdout, stderr) => resolve(error ? "" : `${stdout}\n${stderr}`),
48
+ );
49
+ } catch {
50
+ resolve("");
51
+ }
52
+ });
53
+ }
54
+
55
+ /**
56
+ * Whether the executable behind `invocation` accepts `--no-mcp`. An executable
57
+ * whose version cannot be determined is treated as unsupported, because an
58
+ * unknown flag would make the child exit instead of run. Only determined
59
+ * results are cached per executable for the life of the process.
60
+ */
61
+ export function piSupportsNoMcp(invocation: PiInvocation): Promise<boolean> {
62
+ const key = JSON.stringify([invocation.command, invocation.args]);
63
+ let cached = noMcpSupport.get(key);
64
+ if (!cached) {
65
+ cached = probeVersion(invocation).then((output) => {
66
+ if (parsePiVersion(output) === undefined) noMcpSupport.delete(key);
67
+ return versionSupportsNoMcp(output);
68
+ });
69
+ noMcpSupport.set(key, cached);
70
+ }
71
+ return cached;
72
+ }
@@ -14,6 +14,7 @@ import type { AgentConfig } from "../agents/discovery.ts";
14
14
  import { normalizeMaxConcurrentAgents } from "../config/settings.ts";
15
15
  import type { ThinkingLevel } from "../shared/modelRefs.ts";
16
16
  import { OutputActivityTracker, PartialAssistantAssembler, formatToolActivity } from "./activityTracking.ts";
17
+ import { piSupportsNoMcp, type PiInvocation } from "./piCapabilities.ts";
17
18
  import { escalateKill, mapWithConcurrencyLimit, trackedProcesses } from "./processPool.ts";
18
19
  import { getFinalOutput, type SingleResult } from "./results.ts";
19
20
  import {
@@ -70,7 +71,7 @@ export function getReadOnlyAgentTools(configuredTools?: string[]): string[] {
70
71
  return configuredTools.filter((tool) => READ_ONLY_AGENT_TOOL_SET.has(tool));
71
72
  }
72
73
 
73
- function getPiInvocation(args: string[]): { command: string; args: string[] } {
74
+ function getPiInvocation(args: string[]): PiInvocation {
74
75
  const currentScript = process.argv[1];
75
76
  const isBunVirtualScript = currentScript?.startsWith("/$bunfs/root/");
76
77
  if (currentScript && !isBunVirtualScript && fs.existsSync(currentScript)) {
@@ -90,7 +91,8 @@ export interface RunSingleAgentOptions {
90
91
  /**
91
92
  * Load all configured pi extensions in the child process. Retained as a
92
93
  * fallback for extension-registered providers whose entry point cannot be
93
- * isolated explicitly.
94
+ * isolated explicitly. Such a child also gets --no-mcp when the spawned pi
95
+ * supports it, because loading all extensions keeps pi's built-in MCP.
94
96
  */
95
97
  loadExtensions?: boolean;
96
98
  /**
@@ -118,6 +120,11 @@ export interface RunSingleAgentOptions {
118
120
  resolveOnAbort?: boolean;
119
121
  /** Abort grace period; exposed for focused process-boundary tests. */
120
122
  killTimeoutMs?: number;
123
+ /**
124
+ * Replaces the discovered pi executable (the child args are appended).
125
+ * Exposed for focused process-boundary tests.
126
+ */
127
+ piInvocation?: PiInvocation;
121
128
  }
122
129
 
123
130
  export async function runSingleAgent(
@@ -184,8 +191,7 @@ export async function runSingleAgent(
184
191
  }
185
192
 
186
193
  // argv entries are C strings: a NUL makes spawn throw before execve.
187
- // Follow-up (out of scope): pass the task via temp file or stdin instead of
188
- // argv, which would also remove the E2BIG class of failures.
194
+ // Passing the task via a temp file or stdin instead of argv would remove this failure and the E2BIG class with it.
189
195
  if (task.includes("\0")) {
190
196
  const message = "Task contains a NUL byte and cannot be passed as a process argument";
191
197
  currentResult.exitCode = 1;
@@ -217,11 +223,24 @@ export async function runSingleAgent(
217
223
  args.push("--append-system-prompt", tmpPromptPath);
218
224
  }
219
225
 
226
+ // An all-extension child keeps pi's built-in MCP, whose codemode and
227
+ // tool_search tools can reach write-capable MCP servers regardless of the
228
+ // read-only --tools allowlist (pi >= 1.0.4 keeps MCP tools registered under
229
+ // a plain allowlist). Disable it, but only when the executable this child
230
+ // will run knows the flag. The default and isolated paths already pass
231
+ // --no-extensions, which excludes built-in MCP.
232
+ if (options.loadExtensions && !options.extensionPath) {
233
+ const probe = options.piInvocation ?? getPiInvocation([]);
234
+ if (await piSupportsNoMcp(probe)) args.push("--no-mcp");
235
+ }
236
+
220
237
  args.push(`Task: ${task}`);
221
238
  let wasAborted = false;
222
239
 
223
240
  const exitCode = await new Promise<number>((resolve) => {
224
- const invocation = getPiInvocation(args);
241
+ const invocation = options.piInvocation
242
+ ? { command: options.piInvocation.command, args: [...options.piInvocation.args, ...args] }
243
+ : getPiInvocation(args);
225
244
 
226
245
  const appendDiagnostics = (diagnostics: string) => {
227
246
  currentResult.stderr = (currentResult.stderr + diagnostics).slice(-MAX_DIAGNOSTIC_CHARS);
@@ -371,9 +390,14 @@ export async function runSingleAgent(
371
390
 
372
391
  proc.stdout.on("data", (data) => {
373
392
  resetIdleTimer();
374
- buffer += data.toString();
375
- const lines = buffer.split("\n");
376
- buffer = (lines.pop() || "").slice(-MAX_DIAGNOSTIC_CHARS);
393
+ const chunk = data.toString();
394
+ const newline = chunk.lastIndexOf("\n");
395
+ if (newline === -1) {
396
+ buffer += chunk;
397
+ return;
398
+ }
399
+ const lines = (buffer + chunk.slice(0, newline)).split("\n");
400
+ buffer = chunk.slice(newline + 1);
377
401
  for (const line of lines) processLine(line);
378
402
  });
379
403
 
@@ -257,7 +257,8 @@ class ConflictReviewComponent implements Component {
257
257
  const isAnswered = this.answers.has(conflict.id);
258
258
  const box = isAnswered ? "■" : "□";
259
259
  const color = isAnswered ? "success" : "muted";
260
- const lbl = truncateToWidth(conflict.label, maxTabLabelLen, "…", false);
260
+ const maxLen = isActive ? 30 : maxTabLabelLen;
261
+ const lbl = truncateToWidth(conflict.label, maxLen, "…", false);
261
262
  const text = ` ${box} ${lbl} `;
262
263
  tabParts.push(isActive ? th.bg("selectedBg", th.fg("text", text)) : th.fg(color, text));
263
264
  }
package/src/ui/menu.ts CHANGED
@@ -16,6 +16,8 @@ export interface ToggleMenuItem {
16
16
  id: string;
17
17
  label: string;
18
18
  value: boolean;
19
+ /** Dimmed helper text wrapped under the row. */
20
+ description?: string;
19
21
  onChange?: (value: boolean) => void;
20
22
  }
21
23
 
@@ -386,21 +388,24 @@ export class MenuComponent implements Component {
386
388
  }
387
389
  let flatIndex = 0;
388
390
  const itemLineIndices: number[] = [];
391
+ const itemBlockEndLineIndices: number[] = [];
389
392
  for (const section of this.sections) {
390
393
  lines.push(renderMenuSectionDivider(theme, innerWidth, section.title));
391
394
  const [from, to] = this.itemWindow(section, flatIndex);
392
395
  if (from > 0) lines.push(renderMenuContentRow(theme, innerWidth, theme.fg("dim", ` ↑ ${from} more`)));
393
396
  for (const [index, item] of section.items.entries()) {
394
397
  // The cursor indexes every item, so rows hidden by the window still advance it.
395
- const selected = flatIndex++ === this.cursor;
398
+ const itemIndex = flatIndex++;
399
+ const selected = itemIndex === this.cursor;
396
400
  if (index < from || index >= to) continue;
397
- itemLineIndices[flatIndex - 1] = lines.length;
401
+ itemLineIndices[itemIndex] = lines.length;
398
402
  lines.push(this.renderItemRow(item, selected, innerWidth));
399
403
  const descriptionRows = this.renderDescriptionRows(item, innerWidth);
400
404
  lines.push(...descriptionRows);
401
405
  if (descriptionRows.length > 0 && index < to - 1) {
402
406
  lines.push(renderMenuContentRow(theme, innerWidth, ""));
403
407
  }
408
+ itemBlockEndLineIndices[itemIndex] = lines.length - 1;
404
409
  }
405
410
  const below = section.items.length - to;
406
411
  if (below > 0) lines.push(renderMenuContentRow(theme, innerWidth, theme.fg("dim", ` ↓ ${below} more`)));
@@ -429,8 +434,9 @@ export class MenuComponent implements Component {
429
434
  const middle = lines.slice(1, footerStart);
430
435
  const middleHeight = Math.max(0, this.viewport - top.length - footer.length);
431
436
  const selectedMiddleLine = Math.max(0, (itemLineIndices[this.cursor] ?? 1) - 1);
437
+ const selectedBlockEndLine = Math.max(0, (itemBlockEndLineIndices[this.cursor] ?? itemLineIndices[this.cursor] ?? 1) - 1);
432
438
  if (selectedMiddleLine < this.scrollOffset) this.scrollOffset = selectedMiddleLine;
433
- if (selectedMiddleLine >= this.scrollOffset + middleHeight) this.scrollOffset = selectedMiddleLine - middleHeight + 1;
439
+ if (selectedBlockEndLine >= this.scrollOffset + middleHeight) this.scrollOffset = selectedBlockEndLine - middleHeight + 1;
434
440
  this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, Math.max(0, middle.length - middleHeight)));
435
441
  visibleLines = [...top, ...middle.slice(this.scrollOffset, this.scrollOffset + middleHeight), ...footer];
436
442
  }
@@ -448,9 +454,9 @@ export class MenuComponent implements Component {
448
454
  return [from, from + cap];
449
455
  }
450
456
 
451
- /** Dimmed helper text wrapped under an action/choice row. */
457
+ /** Dimmed helper text wrapped under an action/choice/toggle row. */
452
458
  private renderDescriptionRows(item: MenuItem, innerWidth: number): string[] {
453
- if (isToggleItem(item) || !item.description) return [];
459
+ if (!item.description) return [];
454
460
  const indent = " ";
455
461
  return wrapWords(item.description, Math.max(0, innerWidth - indent.length - 2)).map((line) =>
456
462
  renderMenuContentRow(this.theme, innerWidth, this.theme.fg("dim", indent + line)),
@@ -7,9 +7,12 @@
7
7
  * usage and a pi-topping style generated-output activity meter per agent.
8
8
  * Rows are grouped under `── Plan / Synthesize / Implement / Verify` headings,
9
9
  * with a shimmering four-phase chevron band above the header tracing the
10
- * active phase. The table stays mounted across the whole run — orchestration
11
- * hands it to the plan-mode controller on approval, which keeps it alive
12
- * through in-session implementation and verification.
10
+ * active phase. In minimal mode, the band is hidden and only the active phase
11
+ * name shimmers — its rule and prefix stay flat.
12
+ *
13
+ * The table stays mounted across the whole run — orchestration hands it to the
14
+ * plan-mode controller on approval, which keeps it alive through in-session
15
+ * implementation and verification.
13
16
  *
14
17
  * Each row's MONITOR activity meter is tinted with the thinking level that row
15
18
  * runs under, using that level's native theme color (`thinkingOff` through
@@ -76,7 +79,8 @@ export interface ProposerStatus {
76
79
  turns?: number;
77
80
  /** Tool calls started so far. */
78
81
  toolCalls?: number;
79
- /** Cumulative model cost in USD, calculated from registry rates. */
82
+ /** Cumulative model cost in USD from resolveModelCost: reported per-response
83
+ * costs, or registry rates when none are reported. */
80
84
  costUsd?: number;
81
85
  startedAt?: number;
82
86
  /** Set once the agent settles, freezing its elapsed reading. */
@@ -209,6 +213,8 @@ export interface MoaProgressView {
209
213
  readonly planName: string | undefined;
210
214
  /** Whether live transcript previews are currently shown beneath active rows. */
211
215
  readonly previewVisible: boolean;
216
+ /** Whether the phase/model band is hidden and the active phase name shimmers. */
217
+ readonly minimalFanoutHeader: boolean;
212
218
  /** Display label for a phase without changing its internal identity. */
213
219
  phaseLabel(phase: MoaPhase): string;
214
220
  /** Model assigned to each phase, for the band above the table header. */
@@ -229,6 +235,8 @@ export interface MoaProgressCallbacks {
229
235
  title?: string;
230
236
  phaseLabels?: Partial<Record<MoaPhase, string>>;
231
237
  fanoutWorkingText?: string;
238
+ /** Hide the phase/model band and shimmer the active phase heading instead. Read once at construction. */
239
+ minimalFanoutHeader?: boolean;
232
240
  }
233
241
 
234
242
  interface RowMeter {
@@ -252,6 +260,7 @@ export class MoaProgressWidget implements MoaProgressView {
252
260
  private readonly callbacks: MoaProgressCallbacks;
253
261
  readonly title: string;
254
262
  readonly planName: string | undefined;
263
+ readonly minimalFanoutHeader: boolean;
255
264
 
256
265
  private statuses: ProposerStatus[] = [];
257
266
  private roleRows = new Map<MoaPhase, RoleRow>();
@@ -278,6 +287,7 @@ export class MoaProgressWidget implements MoaProgressView {
278
287
  this.callbacks = callbacks;
279
288
  this.title = callbacks.title ?? TABLE_TITLE;
280
289
  this.planName = planName;
290
+ this.minimalFanoutHeader = callbacks.minimalFanoutHeader ?? false;
281
291
  }
282
292
 
283
293
  private get ui(): ExtensionContext["ui"] {
@@ -783,6 +793,32 @@ export class MoaProgressTableComponent implements Component {
783
793
  return shortModelName(models);
784
794
  }
785
795
 
796
+ /** Active-phase tone shared by the band and, in minimal mode, the table's phase headings. */
797
+ private paintPhase(lit: boolean, text: string, now: number): string {
798
+ const th = this.theme;
799
+ if (!lit) return th.fg("dim", text);
800
+ return th.getFgAnsi
801
+ ? shimmerString(text, now - this.animationStartedAt, th as ShimmerTheme, "ltr", "normal", true)
802
+ : th.fg("text", text);
803
+ }
804
+
805
+ /**
806
+ * Minimal-mode heading with the sweep on the phase name alone: the `── `
807
+ * prefix and the trailing rule stay flat, so the shimmer marks the name
808
+ * rather than washing the whole line. The name is cut to the room left by
809
+ * the prefix and rule, so the line still fills the table body exactly.
810
+ */
811
+ private phaseNameShimmer(label: string, bodyWidth: number, now: number): string {
812
+ const prefix = "── ";
813
+ const suffix = " ";
814
+ const name = fitVisible(label, Math.max(1, bodyWidth - visibleWidth(prefix) - visibleWidth(suffix)), {
815
+ truncationMark: "…",
816
+ padToWidth: false,
817
+ });
818
+ const fill = "─".repeat(Math.max(0, bodyWidth - visibleWidth(prefix) - visibleWidth(name) - visibleWidth(suffix)));
819
+ return `${this.theme.fg("dim", prefix)}${this.paintPhase(true, name, now)}${this.theme.fg("dim", suffix + fill)}`;
820
+ }
821
+
786
822
  /**
787
823
  * Two centered rows — phase names, then their assigned model ids — shown
788
824
  * above the table header, with a two-row powerline chevron standing between
@@ -798,14 +834,7 @@ export class MoaProgressTableComponent implements Component {
798
834
  const colWidth = Math.floor((bodyWidth - sepCount) / MOA_PHASES.length);
799
835
  if (colWidth < PHASE_BAND_MIN_COL) return [];
800
836
 
801
- const th = this.theme;
802
837
  const active = this.view.activePhase();
803
- const paint = (lit: boolean, text: string): string => {
804
- if (!lit) return th.fg("dim", text);
805
- return th.getFgAnsi
806
- ? shimmerString(text, now - this.animationStartedAt, th as ShimmerTheme, "ltr", "normal", true)
807
- : th.fg("text", text);
808
- };
809
838
  // Each boundary carries a two-row powerline chevron: the "\" half on the name
810
839
  // row stacks over the "/" half on the model row. Both rows share one column
811
840
  // layout, so the halves land in the same terminal column and read as a
@@ -814,9 +843,9 @@ export class MoaProgressTableComponent implements Component {
814
843
  const bandRow = (sep: string, textFor: (phase: MoaPhase) => string): string => {
815
844
  const parts: string[] = [];
816
845
  MOA_PHASES.forEach((phase, i) => {
817
- parts.push(paint(phase === active, cell(textFor(phase), colWidth, "center")));
846
+ parts.push(this.paintPhase(phase === active, cell(textFor(phase), colWidth, "center"), now));
818
847
  const next = MOA_PHASES[i + 1];
819
- if (next !== undefined) parts.push(paint(phase === active || next === active, sep));
848
+ if (next !== undefined) parts.push(this.paintPhase(phase === active || next === active, sep, now));
820
849
  });
821
850
  return parts.join("");
822
851
  };
@@ -832,7 +861,7 @@ export class MoaProgressTableComponent implements Component {
832
861
  const border = (s: string) => th.fg("border", s);
833
862
  const row = (s: string) => ` ${fitVisible(s, bodyWidth, { truncationMark: "…", padToWidth: true })} `;
834
863
  const now = Date.now();
835
- const band = this.phaseModelBand(bodyWidth, now);
864
+ const band = this.view.minimalFanoutHeader ? [] : this.phaseModelBand(bodyWidth, now);
836
865
  const rows = this.view.progressRows();
837
866
  const cols = tableColumns(bodyWidth, rows.map((r) => r.label));
838
867
  // Transcript previews stop after ACTIVITY, leaving the right-aligned stats
@@ -926,14 +955,18 @@ export class MoaProgressTableComponent implements Component {
926
955
  }
927
956
  }
928
957
  let renderedPhase = false;
958
+ const activePhase = this.view.activePhase();
929
959
 
930
960
  for (let i = 0; i < rows.length; i++) {
931
961
  const r = rows[i];
932
962
  const active = isActive(r.state);
933
963
  if (r.firstOfPhase) {
934
964
  if (renderedPhase) lines.push(row(""));
935
- const heading = `── ${this.view.phaseLabel(r.phase)} `;
936
- lines.push(row(dim(heading + "─".repeat(Math.max(0, bodyWidth - visibleWidth(heading))))));
965
+ const label = this.view.phaseLabel(r.phase);
966
+ const heading = `── ${label} `;
967
+ const rule = heading + "─".repeat(Math.max(0, bodyWidth - visibleWidth(heading)));
968
+ const lit = this.view.minimalFanoutHeader && r.phase === activePhase;
969
+ lines.push(row(lit ? this.phaseNameShimmer(label, bodyWidth, now) : dim(rule)));
937
970
  renderedPhase = true;
938
971
  }
939
972
  const icon =
@@ -79,6 +79,7 @@ interface SetupDraft {
79
79
  useSummaryName: boolean;
80
80
  maxConcurrentAgents: number;
81
81
  maxVerificationRepairs: number;
82
+ minimalFanoutHeader: boolean;
82
83
  rosters: PlanRoster[];
83
84
  }
84
85
 
@@ -94,6 +95,7 @@ class MoaSetupComponent implements Component {
94
95
  private useSummaryName: boolean;
95
96
  private maxConcurrentAgents: number;
96
97
  private maxVerificationRepairs: number;
98
+ private minimalFanoutHeader: boolean;
97
99
 
98
100
  constructor(
99
101
  private readonly tui: TUI,
@@ -111,6 +113,7 @@ class MoaSetupComponent implements Component {
111
113
  this.useSummaryName = draft.useSummaryName;
112
114
  this.maxConcurrentAgents = draft.maxConcurrentAgents;
113
115
  this.maxVerificationRepairs = draft.maxVerificationRepairs;
116
+ this.minimalFanoutHeader = draft.minimalFanoutHeader;
114
117
  const slotItem = (slotIndex: number): MenuItem => ({
115
118
  id: `role-${slotIndex}`,
116
119
  label: SLOTS[slotIndex]!.menuLabel,
@@ -158,6 +161,13 @@ class MoaSetupComponent implements Component {
158
161
  description: "Verifier-driven repair rounds after implementation (0 disables automatic repairs).",
159
162
  onChange: (valueIndex: number) => { this.maxVerificationRepairs = MIN_VERIFICATION_REPAIRS + valueIndex; },
160
163
  },
164
+ {
165
+ id: "minimal-fanout-header",
166
+ label: "minimal fan-out header",
167
+ value: this.minimalFanoutHeader,
168
+ description: "Hide the phase band and shimmer the active phase heading instead. Applies to the next run.",
169
+ onChange: (value: boolean) => { this.minimalFanoutHeader = value; },
170
+ },
161
171
  ] },
162
172
  ],
163
173
  buttons: [
@@ -198,6 +208,7 @@ class MoaSetupComponent implements Component {
198
208
  useSummaryName: this.useSummaryName,
199
209
  maxConcurrentAgents: this.maxConcurrentAgents,
200
210
  maxVerificationRepairs: this.maxVerificationRepairs,
211
+ minimalFanoutHeader: this.minimalFanoutHeader,
201
212
  rosters: [...this.draft.rosters],
202
213
  };
203
214
  }
@@ -331,6 +342,7 @@ export async function showMoaSetup(
331
342
  useSummaryName: saved.useSummaryName,
332
343
  maxConcurrentAgents: saved.maxConcurrentAgents,
333
344
  maxVerificationRepairs: saved.maxVerificationRepairs,
345
+ minimalFanoutHeader: saved.minimalFanoutHeader,
334
346
  rosters: [...saved.rosters],
335
347
  };
336
348
 
@@ -361,6 +373,7 @@ export async function showMoaSetup(
361
373
  draft.useSummaryName = result.draft.useSummaryName;
362
374
  draft.maxConcurrentAgents = result.draft.maxConcurrentAgents;
363
375
  draft.maxVerificationRepairs = result.draft.maxVerificationRepairs;
376
+ draft.minimalFanoutHeader = result.draft.minimalFanoutHeader;
364
377
  draft.rosters = await showRosterManager(ctx, draft.rosters, {
365
378
  currentThinking,
366
379
  onChange: (rosters) => {
@@ -404,6 +417,7 @@ export async function showMoaSetup(
404
417
  useSummaryName: finalDraft.useSummaryName,
405
418
  maxConcurrentAgents: finalDraft.maxConcurrentAgents,
406
419
  maxVerificationRepairs: finalDraft.maxVerificationRepairs,
420
+ minimalFanoutHeader: finalDraft.minimalFanoutHeader,
407
421
  cheap: finalDraft.selections[AGENT_SLOT_COUNT],
408
422
  rosters: finalDraft.rosters,
409
423
  thinkingOverrides: { ...current.thinkingOverrides, ...finalDraft.thinkingOverrides },
@@ -1,6 +1,7 @@
1
1
  import type { Message } from "@earendil-works/pi-ai";
2
2
  import { getMarkdownTheme, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
3
- import { Key, Markdown, matchesKey, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { Key, Markdown, matchesKey, stripTerminalSequences, truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
4
+ import { stripControlCharacters } from "../shared/controlChars.ts";
4
5
  import { transcriptBody, truncateFromStart } from "./agentTranscript.ts";
5
6
  import { ROUNDED_SINGLE_BOX, UI_TICK_MS, createFrame, ratioViewport, safeRenderWidth } from "./chrome.ts";
6
7
 
@@ -32,7 +33,7 @@ export function formatAgentMarkdown(agent: ObserveAgentData): string {
32
33
  const task = truncateToWidth(agent.task.replace(/\s+/g, " ").trim(), MAX_TASK_WIDTH, "…", false);
33
34
  const header = [`## ${agent.label}`, `_${agent.model} · ${agent.state}_`, "", `**Task:** ${task}`, ""].join("\n");
34
35
  const contentBudget = Math.max(0, MAX_RENDERED_CHARS - header.length - 1);
35
- const body = transcriptBody(agent.messages, agent.partial, agent.state, contentBudget);
36
+ const body = stripControlCharacters(stripTerminalSequences(transcriptBody(agent.messages, agent.partial, agent.state, contentBudget)));
36
37
  const result = `${header}${body}`;
37
38
  return result.length <= MAX_RENDERED_CHARS ? result : truncateFromStart(result, MAX_RENDERED_CHARS);
38
39
  }
@@ -1,6 +1,7 @@
1
1
  import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
3
- import { Key, Markdown, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { Key, Markdown, matchesKey, stripTerminalSequences, type Component, type TUI } from "@earendil-works/pi-tui";
4
+ import { stripControlCharacters } from "../shared/controlChars.ts";
4
5
  import { ROUNDED_SINGLE_BOX, createFrame, ratioViewport, safeRenderWidth } from "./chrome.ts";
5
6
 
6
7
  const OVERLAY_HEIGHT_RATIO = 0.9;
@@ -51,7 +52,7 @@ class VerificationFindingsOverlay implements Component {
51
52
  this.theme = theme;
52
53
  this.keybindings = keybindings;
53
54
  this.done = done;
54
- this.markdown = new Markdown(findingsMarkdown, 0, 0, getMarkdownTheme());
55
+ this.markdown = new Markdown(stripControlCharacters(stripTerminalSequences(findingsMarkdown)), 0, 0, getMarkdownTheme());
55
56
  }
56
57
 
57
58
  private viewportLines(): number {