@underactive/pi-topping-moa-fusion 0.4.5 → 0.5.0

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.5.0]
6
+
7
+ ### Changed
8
+
9
+ - Depend on `@underactive/pi-topping-primitives` `^0.10.0` (was `^0.8.0`). Nothing it imports changed in between: 0.9.0 added `./env` and `./abort`, which it does not use, and 0.10.0 removed only the `pi-topping-compact` entry names from `./contracts`, which it never referenced. Every topping now asks for the same range, so an install of several shares one copy of the library. No visible change.
10
+ - Model references, registry catalogues, label truncation and the unframed model/thinking picker now use `@underactive/pi-topping-primitives` 0.5.0. MoA framing, saved/current thinking preferences and the registry's `off` fallback remain local.
11
+ - Saved model references with an empty provider or id are now treated as unset rather than accepted as valid references.
12
+ - Enter on Select with no matching model is now inert instead of returning to the overview; Cancel and Escape still go back.
13
+ - Model filters now accept printable pasted text and kitty-encoded characters, including while the action buttons are focused. Backspace edits one Unicode code point and returns focus to models.
14
+ - Picker list colours now follow the injected host theme (accent selection and muted descriptions, scroll indicators and no-match text) rather than pi's global select-list theme. Footer hints start at the body edge instead of a two-column inset and can use that extra wrapping space; action buttons keep their alignment.
15
+ - Use `PI_VIRTUAL_MODEL_API` from the shared primitives contracts for virtual-model detection.
16
+ - MoA settings now go through the shared `@underactive/pi-topping-primitives` 0.2.0 settings store. Every read-modify-write (debate, opinion, implementer, planning, roster changes and the setup overlay) is a locked update, which closes the lost-update race between concurrent sessions. The file keeps its path and tab format; writes are atomic, and the file is created 0600 (and its directory 0700 when new).
17
+ - A settings file that is not valid JSON is no longer silently overwritten: it reads as defaults without console output, and saves are refused with `Could not save MoA settings: …` until the file is fixed or removed.
18
+ - A failed settings save now shows that warning and the run continues; only the setup overlay still aborts, returning without reporting success.
19
+ - The vendored activity meter, shimmer, tool-activity labels and output-token tracker now come from `@underactive/pi-topping-primitives` 0.3.0 (`./activity` and `./activity-tui`).
20
+ - The MoA progress table's activity meters now saturate at an inclusive 80 tokens/s instead of a strict 40 (the shared `rateToLevel` default).
21
+ - The shimmer now matches a colour carried inside a compound SGR.
22
+ - Cost formatting (`formatCost`) and the braille and quadrant spinner frames now come from `@underactive/pi-topping-primitives` 0.6.0 (`./text`, `./glyphs`); rendered output is unchanged. The progress table's joined box glyphs stay local.
23
+ - Finding the pi to spawn, the SIGTERM-to-SIGKILL escalation, the tracked-process registry, the concurrency map, the pi version helpers and the agent `.md` loader now come from `@underactive/pi-topping-primitives` 0.8.0 (`./process`). The registry is still this extension's own (`trackedProcesses`), made by the shared `createProcessTracker()`, so `/reload` kills only what moa-fusion started. The `--no-mcp` probe and its cache stay local.
24
+ - An agent file that is malformed (bad YAML, or a non-string `name`, `description`, `tools`, `model` or `thinking`) is now skipped, and the rest of the directory still loads; before, one bad file aborted discovery. A YAML `null` for `tools`, `model` or `thinking` now counts as absent.
25
+ - A Debian-style `nodejs` binary is treated as a generic runtime, like `node` and `bun`, when moa-fusion looks for the pi to spawn.
26
+ - Verified compatibility with pi 1.4.0: no pi export this extension imports was removed or changed, no package entry point moved, and typecheck and the full test suite pass against 1.4.0.
27
+ - Updated all four pi development dependencies from 1.3.0 to 1.4.0.
28
+
29
+ ### Fixed
30
+
31
+ - Tool-activity labels are cleaned (terminal sequences and stray control characters removed) before they are truncated, so an escape sequence is never cut in half.
32
+ - A subagent reply cut off at the model's output-token limit was passed on as if complete. `mf_plan_subagent` output (single and parallel) and `/mf-opinion` answers now start with `[Response was truncated: the model reached its output token limit.]`. The reply still counts as a success: children run in pi's JSON mode, which exits 0 on a truncated response.
33
+
5
34
  ## [0.4.5]
6
35
 
7
36
  ### Changed
package/README.md CHANGED
@@ -367,7 +367,6 @@ In non-interactive modes (`pi -p`, `--mode json`), `exit_plan_mode` is rejected
367
367
  index.ts # Package entry point (re-exports src/index.ts)
368
368
  src/
369
369
  ├── index.ts # Thin composition entry
370
- ├── activityMeter.ts # Dependency-free output-rate meter
371
370
  ├── shared/
372
371
  │ ├── modelRefs.ts # Model refs, thinking levels, display labels
373
372
  │ ├── controlChars.ts # Control-character stripping for argv and display
@@ -457,8 +456,6 @@ src/
457
456
  ├── observeOverlay.ts
458
457
  ├── cancelOverlay.ts
459
458
  ├── promptEditor.ts # File-path/@name completion editor
460
- ├── shimmer.ts
461
- ├── toolActivity.ts
462
459
  ├── verificationFindingsOverlay.ts
463
460
  └── workingOverlay.ts # Blocking-work overlay (plan/opinion/debate naming)
464
461
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@underactive/pi-topping-moa-fusion",
3
- "version": "0.4.5",
3
+ "version": "0.5.0",
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",
@@ -40,7 +40,7 @@
40
40
  "scripts": {
41
41
  "check": "tsc --noEmit",
42
42
  "typecheck": "tsc --noEmit",
43
- "test": "node --test 'test/*.test.mjs'"
43
+ "test": "node -C compiled --experimental-transform-types --test 'test/*.test.mjs'"
44
44
  },
45
45
  "peerDependencies": {
46
46
  "@earendil-works/pi-agent-core": "*",
@@ -50,12 +50,15 @@
50
50
  "typebox": "*"
51
51
  },
52
52
  "devDependencies": {
53
- "@earendil-works/pi-agent-core": "1.3.0",
54
- "@earendil-works/pi-ai": "1.3.0",
55
- "@earendil-works/pi-coding-agent": "1.3.0",
56
- "@earendil-works/pi-tui": "1.3.0",
53
+ "@earendil-works/pi-agent-core": "1.4.0",
54
+ "@earendil-works/pi-ai": "1.4.0",
55
+ "@earendil-works/pi-coding-agent": "1.4.0",
56
+ "@earendil-works/pi-tui": "1.4.0",
57
57
  "@types/node": "^24.0.0",
58
58
  "typebox": "^1.3.3",
59
59
  "typescript": "^5.8.0"
60
+ },
61
+ "dependencies": {
62
+ "@underactive/pi-topping-primitives": "^0.10.0"
60
63
  }
61
64
  }
@@ -1,114 +1,50 @@
1
1
  /**
2
2
  * Agent discovery and configuration.
3
- * Ported from pi's subagent example agents.ts.
3
+ * The per-file and per-directory loading and the upward `.pi/agents` walk live in
4
+ * `@underactive/pi-topping-primitives/process` (ported from pi's subagent example); this module
5
+ * keeps moa-fusion's user-then-project layering and its thinking-level check.
4
6
  */
5
7
 
6
- import * as fs from "node:fs";
7
8
  import * as path from "node:path";
8
- import { CONFIG_DIR_NAME, getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
9
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
10
+ import {
11
+ type AgentDefinition,
12
+ type AgentFileOptions,
13
+ findNearestProjectAgentsDir,
14
+ loadAgentsFromDir,
15
+ parseAgentFile as parsePackageAgentFile,
16
+ } from "@underactive/pi-topping-primitives/process";
9
17
  import { isThinkingLevel, type ThinkingLevel } from "../shared/modelRefs.ts";
10
18
 
11
19
  export type AgentScope = "user" | "project" | "both";
12
20
 
13
- export interface AgentConfig {
14
- name: string;
15
- description: string;
16
- tools?: string[];
17
- model?: string;
18
- thinking?: ThinkingLevel;
19
- systemPrompt: string;
20
- source: "user" | "project";
21
- filePath: string;
22
- }
21
+ export type AgentConfig = AgentDefinition<"user" | "project", ThinkingLevel>;
23
22
 
24
23
  export interface AgentDiscoveryResult {
25
24
  agents: AgentConfig[];
26
25
  projectAgentsDir: string | null;
27
26
  }
28
27
 
28
+ /**
29
+ * An unrecognized level is dropped rather than forwarded: `--thinking` on the child process
30
+ * would reject it and fail the whole agent run.
31
+ */
32
+ const AGENT_FILE_OPTIONS: AgentFileOptions<ThinkingLevel> = Object.freeze({
33
+ parseThinking: (raw: string) => (isThinkingLevel(raw) ? raw : undefined),
34
+ });
35
+
29
36
  /** Parse a single agent markdown file into an `AgentConfig`, or `undefined` if malformed/unreadable. */
30
37
  export function parseAgentFile(filePath: string, source: "user" | "project"): AgentConfig | undefined {
31
- let content: string;
32
- try {
33
- content = fs.readFileSync(filePath, "utf-8");
34
- } catch {
35
- return undefined;
36
- }
37
-
38
- const { frontmatter, body } = parseFrontmatter<Record<string, string>>(content);
39
-
40
- if (!frontmatter.name || !frontmatter.description) return undefined;
41
-
42
- const tools = frontmatter.tools
43
- ?.split(",")
44
- .map((t: string) => t.trim())
45
- .filter(Boolean);
46
-
47
- // An unrecognized level is dropped rather than forwarded: `--thinking` on the
48
- // child process would reject it and fail the whole agent run.
49
- const thinking = frontmatter.thinking?.trim();
50
-
51
- return {
52
- name: frontmatter.name,
53
- description: frontmatter.description,
54
- tools: tools && tools.length > 0 ? tools : undefined,
55
- model: frontmatter.model,
56
- thinking: thinking && isThinkingLevel(thinking) ? thinking : undefined,
57
- systemPrompt: body,
58
- source,
59
- filePath,
60
- };
61
- }
62
-
63
- function loadAgentsFromDir(dir: string, source: "user" | "project"): AgentConfig[] {
64
- const agents: AgentConfig[] = [];
65
-
66
- if (!fs.existsSync(dir)) return agents;
67
-
68
- let entries: fs.Dirent[];
69
- try {
70
- entries = fs.readdirSync(dir, { withFileTypes: true });
71
- } catch {
72
- return agents;
73
- }
74
-
75
- for (const entry of entries) {
76
- if (!entry.name.endsWith(".md")) continue;
77
- if (!entry.isFile() && !entry.isSymbolicLink()) continue;
78
-
79
- const agent = parseAgentFile(path.join(dir, entry.name), source);
80
- if (agent) agents.push(agent);
81
- }
82
-
83
- return agents;
84
- }
85
-
86
- function isDirectory(p: string): boolean {
87
- try {
88
- return fs.statSync(p).isDirectory();
89
- } catch {
90
- return false;
91
- }
92
- }
93
-
94
- function findNearestProjectAgentsDir(cwd: string): string | null {
95
- let currentDir = cwd;
96
- while (true) {
97
- const candidate = path.join(currentDir, CONFIG_DIR_NAME, "agents");
98
- if (isDirectory(candidate)) return candidate;
99
-
100
- const parentDir = path.dirname(currentDir);
101
- if (parentDir === currentDir) return null;
102
- currentDir = parentDir;
103
- }
38
+ return parsePackageAgentFile(filePath, source, AGENT_FILE_OPTIONS);
104
39
  }
105
40
 
106
41
  export function discoverAgents(cwd: string, scope: AgentScope): AgentDiscoveryResult {
107
42
  const userDir = path.join(getAgentDir(), "agents");
108
43
  const projectAgentsDir = findNearestProjectAgentsDir(cwd);
109
44
 
110
- const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user");
111
- const projectAgents = scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project");
45
+ const userAgents = scope === "project" ? [] : loadAgentsFromDir(userDir, "user", AGENT_FILE_OPTIONS);
46
+ const projectAgents =
47
+ scope === "user" || !projectAgentsDir ? [] : loadAgentsFromDir(projectAgentsDir, "project", AGENT_FILE_OPTIONS);
112
48
 
113
49
  const agentMap = new Map<string, AgentConfig>();
114
50
 
@@ -7,11 +7,18 @@
7
7
  * causes it to be skipped.
8
8
  */
9
9
 
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
11
- import { dirname, join } from "node:path";
10
+ import { existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
12
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
13
+ import { defaultThinkingForModel as sharedDefaultThinkingForModel } from "@underactive/pi-topping-primitives/models";
14
+ import {
15
+ createSettingsStore,
16
+ sanitize,
17
+ type SettingsChange,
18
+ type SettingsWriteFailure,
19
+ type SettingsWriteResult,
20
+ } from "@underactive/pi-topping-primitives/settings";
13
21
  import {
14
- THINKING_LEVELS,
15
22
  isModelRef,
16
23
  isThinkingLevel,
17
24
  type ModelRef,
@@ -19,6 +26,8 @@ import {
19
26
  } from "../shared/modelRefs.ts";
20
27
  import { parseRosters, type PlanRoster } from "./rosters.ts";
21
28
 
29
+ export { thinkingOptionsForModel } from "@underactive/pi-topping-primitives/models";
30
+
22
31
  export type MoaMode = "single" | "moa";
23
32
 
24
33
  export const MIN_CONCURRENT_AGENTS = 1;
@@ -111,80 +120,86 @@ export function moaSettingsPath(): string {
111
120
  return join(getAgentDir(), "mf-plan", "settings.json");
112
121
  }
113
122
 
114
- function parseSettingsFile(path: string): MoaConfig {
115
- try {
116
- const raw = readFileSync(path, "utf-8");
117
- const parsed = JSON.parse(raw) as Partial<MoaConfig>;
118
- const proposers = Array.isArray(parsed.proposers)
119
- ? parsed.proposers.filter(isModelRef)
120
- : [];
121
- const opinionModels = Array.isArray(parsed.opinionModels)
122
- ? parsed.opinionModels.filter(isModelRef)
123
- : [];
124
- const debateModels = Array.isArray(parsed.debateModels)
125
- ? parsed.debateModels.filter(isModelRef)
126
- : [];
127
- const debateRounds = typeof parsed.debateRounds === "number" && Number.isFinite(parsed.debateRounds)
128
- ? Math.min(5, Math.max(2, Math.round(parsed.debateRounds)))
129
- : 3;
130
- const maxConcurrentAgents = normalizeMaxConcurrentAgents(parsed.maxConcurrentAgents);
131
- const maxVerificationRepairs = normalizeMaxVerificationRepairs(parsed.maxVerificationRepairs);
132
- const synthesizer = isModelRef(parsed.synthesizer) ? parsed.synthesizer : undefined;
133
- const implementer = isModelRef(parsed.implementer) ? parsed.implementer : undefined;
134
- const verifier = isModelRef(parsed.verifier) ? parsed.verifier : undefined;
135
- const cheap = isModelRef(parsed.cheap) ? parsed.cheap : undefined;
136
- const mode: MoaMode = parsed.mode === "moa" ? "moa" : "single";
137
-
138
- const thinkingOverrides: Record<string, ThinkingLevel> = {};
139
- if (parsed.thinkingOverrides && typeof parsed.thinkingOverrides === "object") {
140
- for (const [key, value] of Object.entries(parsed.thinkingOverrides)) {
141
- if (typeof value === "string" && isThinkingLevel(value)) thinkingOverrides[key] = value;
142
- }
123
+ /** Field-by-field validation; a missing or non-object file reads as the defaults. */
124
+ function sanitizeMoaConfig(raw: unknown): MoaConfig {
125
+ if (raw === undefined || !sanitize.isRecord(raw)) return emptyConfig();
126
+ const parsed = raw as Partial<MoaConfig>;
127
+ const proposers = Array.isArray(parsed.proposers)
128
+ ? parsed.proposers.filter(isModelRef)
129
+ : [];
130
+ const opinionModels = Array.isArray(parsed.opinionModels)
131
+ ? parsed.opinionModels.filter(isModelRef)
132
+ : [];
133
+ const debateModels = Array.isArray(parsed.debateModels)
134
+ ? parsed.debateModels.filter(isModelRef)
135
+ : [];
136
+ const debateRounds = typeof parsed.debateRounds === "number" && Number.isFinite(parsed.debateRounds)
137
+ ? Math.min(5, Math.max(2, Math.round(parsed.debateRounds)))
138
+ : 3;
139
+ const maxConcurrentAgents = normalizeMaxConcurrentAgents(parsed.maxConcurrentAgents);
140
+ const maxVerificationRepairs = normalizeMaxVerificationRepairs(parsed.maxVerificationRepairs);
141
+ const synthesizer = isModelRef(parsed.synthesizer) ? parsed.synthesizer : undefined;
142
+ const implementer = isModelRef(parsed.implementer) ? parsed.implementer : undefined;
143
+ const verifier = isModelRef(parsed.verifier) ? parsed.verifier : undefined;
144
+ const cheap = isModelRef(parsed.cheap) ? parsed.cheap : undefined;
145
+ const mode: MoaMode = parsed.mode === "moa" ? "moa" : "single";
146
+
147
+ const thinkingOverrides: Record<string, ThinkingLevel> = {};
148
+ if (parsed.thinkingOverrides && typeof parsed.thinkingOverrides === "object") {
149
+ for (const [key, value] of Object.entries(parsed.thinkingOverrides)) {
150
+ if (typeof value === "string" && isThinkingLevel(value)) thinkingOverrides[key] = value;
143
151
  }
152
+ }
144
153
 
145
- const autoResolveConflicts = typeof parsed.autoResolveConflicts === "boolean"
146
- ? parsed.autoResolveConflicts
147
- : false;
154
+ const autoResolveConflicts = typeof parsed.autoResolveConflicts === "boolean"
155
+ ? parsed.autoResolveConflicts
156
+ : false;
148
157
 
149
- const useSummaryName = typeof parsed.useSummaryName === "boolean"
150
- ? parsed.useSummaryName
151
- : true;
158
+ const useSummaryName = typeof parsed.useSummaryName === "boolean"
159
+ ? parsed.useSummaryName
160
+ : true;
152
161
 
153
- const minimalFanoutHeader = parsed.minimalFanoutHeader === true;
162
+ const minimalFanoutHeader = parsed.minimalFanoutHeader === true;
154
163
 
155
- const agentDefaultsConfigured = parsed.agentDefaultsConfigured === true;
156
- const rosters = parseRosters(parsed.rosters);
164
+ const agentDefaultsConfigured = parsed.agentDefaultsConfigured === true;
165
+ const rosters = parseRosters(parsed.rosters);
157
166
 
158
- return { mode, proposers, opinionModels, debateModels, debateRounds, maxConcurrentAgents, maxVerificationRepairs, synthesizer, implementer, verifier, cheap, thinkingOverrides, rosters, autoResolveConflicts, useSummaryName, minimalFanoutHeader, agentDefaultsConfigured };
159
- } catch (err) {
160
- console.error("mf-plan: failed to parse settings, using defaults:", err);
161
- return emptyConfig();
162
- }
167
+ return { mode, proposers, opinionModels, debateModels, debateRounds, maxConcurrentAgents, maxVerificationRepairs, synthesizer, implementer, verifier, cheap, thinkingOverrides, rosters, autoResolveConflicts, useSummaryName, minimalFanoutHeader, agentDefaultsConfigured };
163
168
  }
164
169
 
170
+ const moaStore = createSettingsStore<MoaConfig>({
171
+ path: moaSettingsPath,
172
+ codec: {
173
+ sanitize: sanitizeMoaConfig,
174
+ serialize: (config) => `${JSON.stringify(config, null, "\t")}\n`,
175
+ },
176
+ });
177
+
178
+ /** A missing, unreadable or corrupt file reads as the defaults. Never throws, never prints. */
165
179
  export function loadMoaConfig(): MoaConfig {
166
- const settingsPath = moaSettingsPath();
167
- if (existsSync(settingsPath)) return parseSettingsFile(settingsPath);
168
- return emptyConfig();
180
+ return moaStore.read();
169
181
  }
170
182
 
171
- export function saveMoaConfig(config: MoaConfig): void {
172
- const path = moaSettingsPath();
173
- mkdirSync(dirname(path), { recursive: true });
174
- writeFileSync(path, `${JSON.stringify(config, null, "\t")}\n`, "utf-8");
175
- }
176
-
177
- export function moaSettingsExist(): boolean {
178
- return existsSync(moaSettingsPath());
183
+ /** Whole-config write. Prefer `updateMoaConfig` for anything that starts from a loaded config. */
184
+ export function saveMoaConfig(config: MoaConfig): SettingsWriteResult<MoaConfig> {
185
+ return moaStore.write(config);
179
186
  }
180
187
 
181
188
  /**
182
- * The levels to offer for a model, in pi's canonical thinking-level order.
183
- * The registry's declared range is the whole answer — a level it does not list
184
- * is one the backend will refuse.
189
+ * Locked read-modify-write: `change` sees the file as it is now, so two sessions saving
190
+ * different fields both land. Refuses to overwrite a file that is not valid JSON.
185
191
  */
186
- export function thinkingOptionsForModel(registryLevels: ThinkingLevel[]): ThinkingLevel[] {
187
- return THINKING_LEVELS.filter((level) => registryLevels.includes(level));
192
+ export function updateMoaConfig(change: SettingsChange<MoaConfig>): SettingsWriteResult<MoaConfig> {
193
+ return moaStore.update(change);
194
+ }
195
+
196
+ /** Notification text for a failed save. */
197
+ export function moaSaveFailureMessage(result: SettingsWriteFailure): string {
198
+ return `Could not save MoA settings: ${result.message}`;
199
+ }
200
+
201
+ export function moaSettingsExist(): boolean {
202
+ return existsSync(moaSettingsPath());
188
203
  }
189
204
 
190
205
  /** Selects a model's saved level, the current level, or a sensible default in that order. */
@@ -192,12 +207,7 @@ export function defaultThinkingForModel(
192
207
  key: string,
193
208
  config: MoaConfig,
194
209
  currentLevel: ThinkingLevel,
195
- registryLevels: ThinkingLevel[],
210
+ registryLevels: readonly ThinkingLevel[],
196
211
  ): ThinkingLevel {
197
- const options = thinkingOptionsForModel(registryLevels);
198
- const saved = config.thinkingOverrides[key];
199
- if (saved && options.includes(saved)) return saved;
200
- if (options.includes(currentLevel)) return currentLevel;
201
- if (options.includes("medium")) return "medium";
202
- return options[0] ?? "medium";
212
+ return sharedDefaultThinkingForModel(key, config.thinkingOverrides, currentLevel, registryLevels);
203
213
  }
@@ -4,7 +4,7 @@ import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } fr
4
4
  import { discoverAgents } from "../agents/discovery.ts";
5
5
  import { summarizePlanPromptName } from "../config/planName.ts";
6
6
  import { slugifyPlanName } from "../planning/planFile.ts";
7
- import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
7
+ import { loadMoaConfig, moaSaveFailureMessage, updateMoaConfig } from "../config/settings.ts";
8
8
  import type { OpinionHost } from "../opinion/runOpinion.ts";
9
9
  import { resolveContextWindow } from "../moa/modelRuntime.ts";
10
10
  import { type CancelSession, subscribeCancelOverlayOnEsc } from "../runtime/cancelRun.ts";
@@ -63,12 +63,13 @@ export async function runInteractiveDebate(
63
63
  if (!selection) return;
64
64
  const { models, thinking, thinkingSelections, rounds } = selection;
65
65
  const settings = loadMoaConfig();
66
- saveMoaConfig({
67
- ...settings,
66
+ const persisted = updateMoaConfig((current) => ({
67
+ ...current,
68
68
  debateModels: models,
69
69
  debateRounds: rounds,
70
- thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections },
71
- });
70
+ thinkingOverrides: { ...current.thinkingOverrides, ...thinkingSelections },
71
+ }));
72
+ if (!persisted.ok) ctx.ui.notify(moaSaveFailureMessage(persisted), "warning");
72
73
 
73
74
  const slug = await withWorkingOverlay(
74
75
  ctx,
@@ -1,34 +1,18 @@
1
1
  import { calculateCost, type Api, type Model } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { PI_VIRTUAL_MODEL_API } from "@underactive/pi-topping-primitives/contracts";
4
5
  import * as fs from "node:fs";
5
6
  import * as path from "node:path";
6
7
 
7
- import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
8
+ import { moaSaveFailureMessage, updateMoaConfig } from "../config/settings.ts";
8
9
  import type { UsageStats } from "../runtime/results.ts";
9
10
  import { modelRefLabel, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
10
11
 
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
- */
28
12
  export function modelUsesExtensionRegisteredProvider(ctx: ExtensionContext, ref: ModelRef): boolean {
29
13
  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;
14
+ // Only an extension can register a virtual model, so one mandates loadExtensions in the child.
15
+ return ctx.modelRegistry.find(ref.provider, ref.id)?.api === PI_VIRTUAL_MODEL_API;
32
16
  }
33
17
 
34
18
  export function cursorBridgeExtensionPath(): string | undefined {
@@ -130,12 +114,12 @@ export async function applyImplementingSelection(
130
114
  ctx.ui.notify(`No API key for ${modelRefLabel(selection.ref)}; keeping current model.`, "warning");
131
115
  return;
132
116
  }
133
- const settings = loadMoaConfig();
134
- saveMoaConfig({
117
+ const saved = updateMoaConfig((settings) => ({
135
118
  ...settings,
136
119
  implementer: selection.ref,
137
120
  thinkingOverrides: { ...settings.thinkingOverrides, [modelRefLabel(selection.ref)]: selection.thinking },
138
- });
121
+ }));
122
+ if (!saved.ok) ctx.ui.notify(moaSaveFailureMessage(saved), "warning");
139
123
  pi.setThinkingLevel(selection.thinking);
140
124
  ctx.ui.notify(successNotice);
141
125
  }
@@ -1,7 +1,7 @@
1
1
  import type { ModelRef, ThinkingLevel } from "../shared/modelRefs.ts";
2
2
  import { modelRefLabel } from "../shared/modelRefs.ts";
3
3
  import type { SingleResult } from "../runtime/results.ts";
4
- import { getFinalOutput, getResultOutput, isFailedResult } from "../runtime/results.ts";
4
+ import { getResultOutput, isFailedResult } from "../runtime/results.ts";
5
5
 
6
6
  export interface OpinionOutcome {
7
7
  ref: ModelRef;
@@ -30,7 +30,7 @@ export function collectOpinionOutcomes(
30
30
  ref,
31
31
  thinking: thinking[index],
32
32
  status: "done",
33
- text: getFinalOutput(result.messages).trim() || "(no output)",
33
+ text: getResultOutput(result).trim() || "(no output)",
34
34
  };
35
35
  });
36
36
  }
@@ -4,7 +4,7 @@ import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } fr
4
4
  import { discoverAgents } from "../agents/discovery.ts";
5
5
  import { summarizePlanPromptName } from "../config/planName.ts";
6
6
  import { slugifyPlanName } from "../planning/planFile.ts";
7
- import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
7
+ import { loadMoaConfig, moaSaveFailureMessage, updateMoaConfig } from "../config/settings.ts";
8
8
  import { resolveContextWindow } from "../moa/modelRuntime.ts";
9
9
  import { type CancelSession, subscribeCancelOverlayOnEsc } from "../runtime/cancelRun.ts";
10
10
  import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
@@ -70,11 +70,12 @@ export async function runInteractiveOpinion(
70
70
  if (!selection) return;
71
71
  const { models, thinking, thinkingSelections } = selection;
72
72
  const settings = loadMoaConfig();
73
- saveMoaConfig({
74
- ...settings,
73
+ const persisted = updateMoaConfig((current) => ({
74
+ ...current,
75
75
  opinionModels: models,
76
- thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections },
77
- });
76
+ thinkingOverrides: { ...current.thinkingOverrides, ...thinkingSelections },
77
+ }));
78
+ if (!persisted.ok) ctx.ui.notify(moaSaveFailureMessage(persisted), "warning");
78
79
 
79
80
  const slug = await withWorkingOverlay(
80
81
  ctx,
@@ -15,6 +15,8 @@ import type {
15
15
  ThemeColor,
16
16
  ToolExecutionStartEvent,
17
17
  } from "@earendil-works/pi-coding-agent";
18
+ import { OutputTokenAccount } from "@underactive/pi-topping-primitives/activity";
19
+ import { formatToolActivity } from "@underactive/pi-topping-primitives/activity-tui";
18
20
 
19
21
  import { installShippedAgents } from "../agents/authoritative.ts";
20
22
  import { summarizePlanPromptName } from "../config/planName.ts";
@@ -29,7 +31,7 @@ import { runImplementationVerification } from "../moa/verification.ts";
29
31
  import { applyImplementingSelection, resolveContextWindow, resolveModelCost } from "../moa/modelRuntime.ts";
30
32
  import { validateMfPlanInfo, type MfPlanInfo } from "../moa/planInfo.ts";
31
33
  import type { MoaRunHost } from "../moa/runContext.ts";
32
- import { formatToolActivity, OutputActivityTracker, PartialAssistantAssembler } from "../runtime/activityTracking.ts";
34
+ import { PartialAssistantAssembler } from "../runtime/activityTracking.ts";
33
35
  import type { CancelSession } from "../runtime/cancelRun.ts";
34
36
  import { cleanupTrackedProcesses } from "../runtime/processPool.ts";
35
37
  import type { UsageStats } from "../runtime/results.ts";
@@ -141,7 +143,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
141
143
  // synthesis and review still own the table.
142
144
  let runningProgressWidget: MoaProgressWidget | undefined;
143
145
  // In-session implementer telemetry, reset each time implementation starts.
144
- let implementationOutputTracker: OutputActivityTracker | undefined;
146
+ let implementationOutputTracker: OutputTokenAccount | undefined;
145
147
  let implementationMessages: Message[] = [];
146
148
  const implementationPartial = new PartialAssistantAssembler();
147
149
  let implementationUsage: UsageStats | undefined;
@@ -470,7 +472,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
470
472
  markImplementationPending: (ctx) => {
471
473
  footerError = undefined;
472
474
  implementationPending = true;
473
- implementationOutputTracker = new OutputActivityTracker();
475
+ implementationOutputTracker = new OutputTokenAccount();
474
476
  resetImplementationCounters();
475
477
  // Reconstruct the table for a resumed run that never adopted one (e.g.
476
478
  // /mf-plan-implement after a restart). In-run paths already hold the
@@ -3,7 +3,7 @@ import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
3
3
  import { Type } from "typebox";
4
4
 
5
5
  import { installShippedAgents } from "../../agents/authoritative.ts";
6
- import { loadMoaConfig, saveMoaConfig } from "../../config/settings.ts";
6
+ import { loadMoaConfig, moaSaveFailureMessage, updateMoaConfig } from "../../config/settings.ts";
7
7
  import { runMoaOrchestration } from "../../moa/orchestration.ts";
8
8
  import type { MfPlanInfo } from "../../moa/planInfo.ts";
9
9
  import type { MoaRunHost } from "../../moa/runContext.ts";
@@ -102,9 +102,9 @@ export async function runInteractivePlanMode(
102
102
  pi.sendUserMessage(trimmedPrompt, TRIGGER_TURN);
103
103
  return;
104
104
  }
105
- const settings = loadMoaConfig();
106
105
  const { proposers, synthesizer, implementer, verifier, proposerThinking, synthesizerThinking, implementerThinking, verifierThinking, thinkingSelections } = pickerResult;
107
- saveMoaConfig({ ...settings, mode: "moa", proposers, synthesizer, implementer, verifier, thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections } });
106
+ const saved = updateMoaConfig((settings) => ({ ...settings, mode: "moa", proposers, synthesizer, implementer, verifier, thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections } }));
107
+ if (!saved.ok) ctx.ui.notify(moaSaveFailureMessage(saved), "warning");
108
108
  host.setActiveRunMoaInfo({ proposers, synthesizer });
109
109
  const outcome = await runMoaOrchestration(host.moaRunHost, ctx, trimmedPrompt, proposers, synthesizer, proposerThinking, synthesizerThinking, session, { implementer, implementerThinking, verifier, verifierThinking });
110
110
  if (outcome === "cancelled") { session.closeOverlay?.(); continue; }
@@ -11,7 +11,7 @@ import { discoverAgents, parseAgentFile } from "../../agents/discovery.ts";
11
11
  import { modelExtensionOptions, qualifyBareModelId } from "../../moa/modelRuntime.ts";
12
12
  import { CancelRun, type CancelRowExtras, type CancelSession } from "../../runtime/cancelRun.ts";
13
13
  import { MutationTripwire, formatMutationWarning } from "../../runtime/mutationTripwire.ts";
14
- import { getFinalOutput, getResultOutput, isFailedResult, truncateOutput } from "../../runtime/results.ts";
14
+ import { getResultOutput, isFailedResult, truncateOutput } from "../../runtime/results.ts";
15
15
  import { runParallelAgents, runSingleAgent } from "../../runtime/runner.ts";
16
16
  import { parseRef as parseModelRefLabel, type ThinkingLevel } from "../../shared/modelRefs.ts";
17
17
  import { activityLoopCount } from "../../ui/agentStatus.ts";
@@ -328,7 +328,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
328
328
  }
329
329
  return {
330
330
  details: undefined,
331
- content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
331
+ content: [{ type: "text", text: getResultOutput(result) }],
332
332
  structuredContent,
333
333
  };
334
334
  } finally {