@underactive/pi-topping-moa-fusion 0.1.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.
Files changed (88) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +437 -0
  4. package/agents/mf-plan.md +43 -0
  5. package/agents/moa-debater.md +37 -0
  6. package/agents/moa-explore.md +56 -0
  7. package/agents/moa-opinion.md +29 -0
  8. package/agents/moa-proposer.md +49 -0
  9. package/agents/moa-synthesizer.md +124 -0
  10. package/agents/moa-verifier.md +67 -0
  11. package/index.ts +3 -0
  12. package/package.json +61 -0
  13. package/src/activityMeter.ts +193 -0
  14. package/src/agents/authoritative.ts +91 -0
  15. package/src/agents/defaults.ts +123 -0
  16. package/src/agents/discovery.ts +119 -0
  17. package/src/config/modelCatalogue.ts +54 -0
  18. package/src/config/planName.ts +74 -0
  19. package/src/config/rosters.ts +118 -0
  20. package/src/config/settings.ts +161 -0
  21. package/src/debate/debateContract.ts +89 -0
  22. package/src/debate/debateFanout.ts +285 -0
  23. package/src/debate/debateFile.ts +38 -0
  24. package/src/debate/debateResults.ts +115 -0
  25. package/src/debate/debateRounds.ts +61 -0
  26. package/src/debate/runDebate.ts +143 -0
  27. package/src/index.ts +283 -0
  28. package/src/moa/conflictContract.ts +49 -0
  29. package/src/moa/conflicts.ts +153 -0
  30. package/src/moa/contextContract.ts +52 -0
  31. package/src/moa/fanout.ts +152 -0
  32. package/src/moa/fanoutWiring.ts +88 -0
  33. package/src/moa/implementationRetry.ts +292 -0
  34. package/src/moa/modelRuntime.ts +87 -0
  35. package/src/moa/orchestration.ts +105 -0
  36. package/src/moa/planInfo.ts +57 -0
  37. package/src/moa/planlessRetry.ts +72 -0
  38. package/src/moa/reviewLoop.ts +170 -0
  39. package/src/moa/runContext.ts +118 -0
  40. package/src/moa/synthesis.ts +420 -0
  41. package/src/moa/verdicts.ts +81 -0
  42. package/src/moa/verification.ts +791 -0
  43. package/src/moa/verificationCriteria.ts +127 -0
  44. package/src/moa/verifyGate.ts +137 -0
  45. package/src/opinion/opinionContract.ts +21 -0
  46. package/src/opinion/opinionFanout.ts +135 -0
  47. package/src/opinion/opinionFile.ts +38 -0
  48. package/src/opinion/opinionResults.ts +73 -0
  49. package/src/opinion/runOpinion.ts +156 -0
  50. package/src/planning/askUserQuestion.ts +83 -0
  51. package/src/planning/instructions.ts +146 -0
  52. package/src/planning/modeState.ts +61 -0
  53. package/src/planning/planFile.ts +273 -0
  54. package/src/planning/planMode.ts +673 -0
  55. package/src/planning/tools/enterPlanMode.ts +165 -0
  56. package/src/planning/tools/exitPlanMode.ts +159 -0
  57. package/src/planning/tools/mfPlanSubagent.ts +311 -0
  58. package/src/planning/tools/shared.ts +19 -0
  59. package/src/planning/tools/writePlan.ts +33 -0
  60. package/src/runtime/activityTracking.ts +141 -0
  61. package/src/runtime/cancelRun.ts +134 -0
  62. package/src/runtime/mutationTripwire.ts +251 -0
  63. package/src/runtime/processPool.ts +55 -0
  64. package/src/runtime/results.ts +103 -0
  65. package/src/runtime/runner.ts +538 -0
  66. package/src/runtime/wire.ts +177 -0
  67. package/src/shared/functionKeys.ts +30 -0
  68. package/src/shared/modelRefs.ts +91 -0
  69. package/src/ui/agentStatus.ts +84 -0
  70. package/src/ui/agentTranscript.ts +112 -0
  71. package/src/ui/cancelOverlay.ts +191 -0
  72. package/src/ui/chrome.ts +151 -0
  73. package/src/ui/conflictOverlay.ts +363 -0
  74. package/src/ui/debateModelPicker.ts +273 -0
  75. package/src/ui/menu.ts +679 -0
  76. package/src/ui/moaModelPicker.ts +900 -0
  77. package/src/ui/moaProgressWidget.ts +910 -0
  78. package/src/ui/moaSetupOverlay.ts +368 -0
  79. package/src/ui/modelLabel.ts +61 -0
  80. package/src/ui/observeOverlay.ts +206 -0
  81. package/src/ui/opinionModelPicker.ts +246 -0
  82. package/src/ui/planReviewOverlay.ts +315 -0
  83. package/src/ui/promptEditor.ts +87 -0
  84. package/src/ui/rosterEditor.ts +310 -0
  85. package/src/ui/shimmer.ts +77 -0
  86. package/src/ui/toolActivity.ts +35 -0
  87. package/src/ui/twoPaneModelThinking.ts +272 -0
  88. package/src/ui/verificationFindingsOverlay.ts +137 -0
@@ -0,0 +1,673 @@
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import type { Message, TextContent } from "@earendil-works/pi-ai";
3
+ import { Key } from "@earendil-works/pi-tui";
4
+ import type {
5
+ AgentSettledEvent,
6
+ AgentStartEvent,
7
+ ExtensionAPI,
8
+ ExtensionContext,
9
+ MessageEndEvent,
10
+ MessageStartEvent,
11
+ MessageUpdateEvent,
12
+ ThemeColor,
13
+ ToolExecutionStartEvent,
14
+ } from "@earendil-works/pi-coding-agent";
15
+
16
+ import { installShippedAgents } from "../agents/authoritative.ts";
17
+ import { summarizePlanPromptName } from "../config/planName.ts";
18
+ import { loadMoaConfig } from "../config/settings.ts";
19
+ import {
20
+ type ImplementationHandoff,
21
+ implementationTurnFailed,
22
+ runImplementationRetryFlow,
23
+ validateImplementationHandoff,
24
+ } from "../moa/implementationRetry.ts";
25
+ import { runImplementationVerification } from "../moa/verification.ts";
26
+ import { applyImplementingSelection, resolveContextWindow, resolveModelCost } from "../moa/modelRuntime.ts";
27
+ import { validateMfPlanInfo, type MfPlanInfo } from "../moa/planInfo.ts";
28
+ import type { MoaRunHost } from "../moa/runContext.ts";
29
+ import { formatToolActivity, OutputActivityTracker, PartialAssistantAssembler } from "../runtime/activityTracking.ts";
30
+ import type { CancelSession } from "../runtime/cancelRun.ts";
31
+ import { cleanupTrackedProcesses } from "../runtime/processPool.ts";
32
+ import type { UsageStats } from "../runtime/results.ts";
33
+ import { READ_ONLY_SUBAGENT_ENV } from "../runtime/runner.ts";
34
+ import type { ModelRef, ThinkingLevel } from "../shared/modelRefs.ts";
35
+ import { matchesFunctionKeyPress } from "../shared/functionKeys.ts";
36
+ import { showCancelOverlay } from "../ui/cancelOverlay.ts";
37
+ import { UI_TICK_MS } from "../ui/chrome.ts";
38
+ import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
39
+ import { showObserveOverlay, type ObserveSession } from "../ui/observeOverlay.ts";
40
+ import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTracker, isAskUserQuestionInstalled } from "./askUserQuestion.ts";
41
+ import { buildPlanModeExitInstructions, buildPlanModeInstructions, buildPlanModeReentryInstructions } from "./instructions.ts";
42
+ import { latestPlanModeStateEntry, PlanModeStatePersistence } from "./modeState.ts";
43
+ import { getPlan, getPlanSlug, isValidPlanSlug, resetPlanSlug, saveRepoPlanFile, setPlanSlug } from "./planFile.ts";
44
+ import { PLAN_EXIT_CONTEXT_TYPE, PLAN_MODE_CONTEXT_TYPE, PLAN_MODE_CUSTOM_TOOLS, PLAN_MODE_READ_ONLY_TOOLS, PLAN_ONLY_REGISTERED_TOOLS } from "./tools/shared.ts";
45
+
46
+ /** Trailing chars of the implementer report kept for the verifier's evidence. */
47
+ const IMPLEMENTATION_REPORT_MAX_CHARS = 8000;
48
+ const IMPLEMENTATION_TRANSCRIPT_MESSAGE_LIMIT = 40;
49
+
50
+ type MoaFooterState = "working" | "waiting" | "error";
51
+ const FOOTER_STATUS_KEY = "mf-plan";
52
+ const FOOTER_DOT = "●";
53
+ const FOOTER_DOT_COLOR: Record<MoaFooterState, ThemeColor> = {
54
+ working: "success",
55
+ waiting: "warning",
56
+ error: "error",
57
+ };
58
+
59
+ /** Clamp unknown usage fields to non-negative numbers, mirroring the wire-runner accounting. */
60
+ function nonnegative(value: unknown): number {
61
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : 0;
62
+ }
63
+
64
+ /** The subset of an in-process assistant message's usage the telemetry accumulator reads. */
65
+ interface AssistantUsageLike {
66
+ input?: number;
67
+ output?: number;
68
+ cacheRead?: number;
69
+ cacheWrite?: number;
70
+ cacheWrite1h?: number;
71
+ totalTokens?: number;
72
+ cost?: { total?: number };
73
+ }
74
+
75
+ /** Flatten an assistant message's content (string or text parts) to plain text. */
76
+ function extractAssistantText(content: unknown): string {
77
+ if (typeof content === "string") return content;
78
+ if (Array.isArray(content)) {
79
+ return content
80
+ .filter((part): part is { type?: string; text?: string } => !!part && typeof part === "object")
81
+ .filter((part) => part.type === "text" && typeof part.text === "string")
82
+ .map((part) => part.text as string)
83
+ .join("\n");
84
+ }
85
+ return "";
86
+ }
87
+
88
+ export function createPlanModeController(pi: ExtensionAPI) {
89
+ let planModeEnabled = false;
90
+ let toolsBeforePlanMode: string[] | undefined;
91
+ let needsExitReminder = false;
92
+ let planSlug: string | undefined;
93
+ let planRepoSlug: string | undefined;
94
+ let lastReentryState = false;
95
+ let activeCancelSession: CancelSession | undefined;
96
+ let activeObserveSession: ObserveSession | undefined;
97
+ let unsubscribeLivePreviewInput: (() => void) | undefined;
98
+ let activeRunMoaInfo: MfPlanInfo | undefined;
99
+ let implementationHandoff: ImplementationHandoff | undefined;
100
+ let implementationPending = false;
101
+ let lastImplementationStopReason: string | undefined;
102
+ // The implementer's final assistant text, captured while pending so the
103
+ // verifier can be handed the implementer's own (untrusted) report. Capped to
104
+ // the trailing slice so a very long turn never bloats state.
105
+ let lastImplementationReport: string | undefined;
106
+ // The one MoA progress table, adopted from orchestration on approval and kept
107
+ // alive across in-session implementation and verification.
108
+ let activeProgressWidget: MoaProgressWidget | undefined;
109
+ // Latched unrecoverable error: red until the user starts new work. Only set
110
+ // by flows that stop with nothing left to try (see noteRunError call sites).
111
+ let footerError: string | undefined;
112
+ let lastFooterCtx: ExtensionContext | undefined;
113
+ let lastRenderedFooter: string | undefined;
114
+ let footerTimer: ReturnType<typeof setInterval> | undefined;
115
+ // Orchestration publishes its widget here before approval, while fan-out,
116
+ // synthesis and review still own the table.
117
+ let runningProgressWidget: MoaProgressWidget | undefined;
118
+ // In-session implementer telemetry, reset each time implementation starts.
119
+ let implementationOutputTracker: OutputActivityTracker | undefined;
120
+ let implementationMessages: Message[] = [];
121
+ const implementationPartial = new PartialAssistantAssembler();
122
+ let implementationUsage: UsageStats | undefined;
123
+ let implementationContextTokens: number | undefined;
124
+ let implementationTurns = 0;
125
+ let implementationToolCalls = 0;
126
+ let providerEnvBeforePlanMode: Record<string, string | undefined> | undefined;
127
+ const persistence = new PlanModeStatePersistence(pi);
128
+ // Tracks whether rpiv's ask_user_question (or whichever extension registers
129
+ // that tool) is currently blocking the user. Owned by this controller rather
130
+ // than a module singleton so the fake-`pi` tests stay self-contained and no
131
+ // state leaks across sessions.
132
+ const askUserQuestionTracker = createAskUserQuestionTracker(pi, () => {
133
+ if (lastFooterCtx) updateStatus(lastFooterCtx);
134
+ });
135
+ // Subscribe immediately so the blocked flag is live from extension load, not
136
+ // only after the first session_start (which the test harnesses never fire).
137
+ // onSessionStart still resets + re-subscribes for the reload/stuck-flag case.
138
+ askUserQuestionTracker.ensureSubscribed();
139
+
140
+ const resetImplementationTranscript = (): void => {
141
+ implementationMessages = [];
142
+ implementationPartial.clear();
143
+ };
144
+ const currentThinkingLevel = (): ThinkingLevel => pi.getThinkingLevel();
145
+ const stopActiveProgressWidget = (): void => {
146
+ activeProgressWidget?.stopWidget();
147
+ activeProgressWidget = undefined;
148
+ };
149
+ const resetImplementationCounters = (): void => {
150
+ lastImplementationStopReason = undefined;
151
+ lastImplementationReport = undefined;
152
+ resetImplementationTranscript();
153
+ implementationUsage = undefined;
154
+ implementationContextTokens = undefined;
155
+ implementationTurns = 0;
156
+ implementationToolCalls = 0;
157
+ };
158
+ const resetImplementationState = (): void => {
159
+ implementationPending = false;
160
+ implementationOutputTracker = undefined;
161
+ resetImplementationCounters();
162
+ stopActiveProgressWidget();
163
+ };
164
+ const persistState = (): void => persistence.persist({
165
+ enabled: planModeEnabled,
166
+ slug: planSlug ?? getPlanSlug(),
167
+ repoPlanSlug: planRepoSlug,
168
+ needsExitReminder,
169
+ moaInfo: activeRunMoaInfo,
170
+ implementationHandoff,
171
+ });
172
+ const footerState = (ctx: ExtensionContext): MoaFooterState => {
173
+ if (footerError) return "error";
174
+ // The one state inference cannot see: the questionnaire blocks the user
175
+ // from inside an active agent run, so isIdle() is false while waiting.
176
+ if (askUserQuestionTracker.isActive()) return "waiting";
177
+ if (!ctx.isIdle() || implementationPending || activeCancelSession?.run) return "working";
178
+ return "waiting";
179
+ };
180
+ const renderFooter = (ctx: ExtensionContext): string | undefined => {
181
+ const runActive = implementationPending || activeProgressWidget !== undefined || activeCancelSession?.run !== undefined;
182
+ if (!planModeEnabled && !runActive && !footerError) return undefined;
183
+ const dot = ctx.ui.theme.fg(FOOTER_DOT_COLOR[footerState(ctx)], FOOTER_DOT);
184
+ const label = planModeEnabled ? "MoA Fusion (plan mode)" : "MoA Fusion";
185
+ return `${dot} ${ctx.ui.theme.fg("dim", label)}`;
186
+ };
187
+ const updateStatus = (ctx: ExtensionContext): void => {
188
+ try {
189
+ const rendered = renderFooter(ctx);
190
+ if (rendered === lastRenderedFooter) return;
191
+ lastRenderedFooter = rendered;
192
+ ctx.ui.setStatus(FOOTER_STATUS_KEY, rendered);
193
+ lastFooterCtx = ctx;
194
+ } catch {
195
+ // pi's ctx getters throw once the ctx is stale (post-/reload). Stop
196
+ // touching it until the next live event supplies a fresh context.
197
+ lastFooterCtx = undefined;
198
+ }
199
+ };
200
+ const stopFooterTicker = (): void => {
201
+ if (footerTimer) clearInterval(footerTimer);
202
+ footerTimer = undefined;
203
+ };
204
+ const startFooterTicker = (ctx: ExtensionContext): void => {
205
+ stopFooterTicker();
206
+ lastFooterCtx = ctx;
207
+ if (ctx.mode !== "tui") return;
208
+ // Subprocess phases fire no pi events, so repaint on the shared UI pulse.
209
+ // The dirty-check avoids repeated status writes while state is unchanged.
210
+ footerTimer = setInterval(() => {
211
+ const tickCtx = lastFooterCtx;
212
+ if (tickCtx) updateStatus(tickCtx);
213
+ }, UI_TICK_MS);
214
+ footerTimer.unref();
215
+ };
216
+ const getPlanModeTools = (activeToolNames: string[]): string[] => {
217
+ const filtered = activeToolNames.filter((name) => PLAN_MODE_READ_ONLY_TOOLS.has(name));
218
+ const customTools = PLAN_MODE_CUSTOM_TOOLS.filter((name) => name !== ASK_USER_QUESTION_TOOL_NAME || isAskUserQuestionInstalled(pi));
219
+ return [...new Set([...filtered, ...customTools])];
220
+ };
221
+ const applyReadOnlyProviderEnv = (): void => {
222
+ if (providerEnvBeforePlanMode === undefined) {
223
+ providerEnvBeforePlanMode = {};
224
+ for (const key of Object.keys(READ_ONLY_SUBAGENT_ENV)) providerEnvBeforePlanMode[key] = process.env[key];
225
+ }
226
+ Object.assign(process.env, READ_ONLY_SUBAGENT_ENV);
227
+ };
228
+ const restoreReadOnlyProviderEnv = (): void => {
229
+ if (providerEnvBeforePlanMode === undefined) return;
230
+ for (const [key, value] of Object.entries(providerEnvBeforePlanMode)) {
231
+ if (value === undefined) delete process.env[key];
232
+ else process.env[key] = value;
233
+ }
234
+ providerEnvBeforePlanMode = undefined;
235
+ };
236
+ const enablePlanModeTools = (): void => {
237
+ if (toolsBeforePlanMode === undefined) toolsBeforePlanMode = pi.getActiveTools();
238
+ applyReadOnlyProviderEnv();
239
+ pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
240
+ };
241
+ const deactivatePlanOnlyTools = (): void => {
242
+ const active = pi.getActiveTools();
243
+ const filtered = active.filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name));
244
+ if (filtered.length !== active.length) pi.setActiveTools(filtered);
245
+ };
246
+ const restoreNormalModeTools = (): void => {
247
+ const restored = toolsBeforePlanMode ?? pi.getActiveTools();
248
+ restoreReadOnlyProviderEnv();
249
+ pi.setActiveTools(restored);
250
+ deactivatePlanOnlyTools();
251
+ toolsBeforePlanMode = undefined;
252
+ };
253
+ const questionnaireBusy = (ctx: ExtensionContext): boolean => {
254
+ if (!askUserQuestionTracker.isActive()) return false;
255
+ ctx.ui.notify("Answer or dismiss the ask_user_question questionnaire first.", "warning");
256
+ return true;
257
+ };
258
+ const toggleLivePreviewWidget = (ctx: ExtensionContext): boolean => {
259
+ if (questionnaireBusy(ctx)) return false;
260
+ if (activeCancelSession?.overlayOpen || activeObserveSession?.overlayOpen) return false;
261
+ const widget = runningProgressWidget ?? activeProgressWidget;
262
+ if (!widget) {
263
+ ctx.ui.notify("No MoA live preview is showing.", "warning");
264
+ return false;
265
+ }
266
+ const visible = widget.togglePreview();
267
+ ctx.ui.notify(visible ? "Live preview shown." : "Live preview hidden.");
268
+ return true;
269
+ };
270
+ const openCancelOverlayIfActive = (ctx: ExtensionContext): boolean => {
271
+ const session = activeCancelSession;
272
+ if (!session || session.overlayOpen || !session.run || ctx.mode !== "tui") return false;
273
+ // Silent: a live questionnaire owns the keyboard (Esc/F4 pass through to
274
+ // it), so mf-plan must not open a competing overlay. Returns false so the
275
+ // raw key listeners treat the key as unconsumed.
276
+ if (askUserQuestionTracker.isActive()) return false;
277
+ session.overlayOpen = true;
278
+ void showCancelOverlay(ctx, session).finally(() => { session.overlayOpen = false; });
279
+ return true;
280
+ };
281
+ const openObserveOverlayIfActive = (ctx: ExtensionContext): boolean => {
282
+ const session = activeObserveSession;
283
+ if (!session || session.overlayOpen || ctx.mode !== "tui") return false;
284
+ // Silent, as above: a live questionnaire owns the keyboard.
285
+ if (askUserQuestionTracker.isActive()) return false;
286
+ session.overlayOpen = true;
287
+ void showObserveOverlay(ctx, session).finally(() => { session.overlayOpen = false; });
288
+ return true;
289
+ };
290
+ const abortPlanMode = (ctx: ExtensionContext): void => {
291
+ planModeEnabled = false;
292
+ footerError = undefined;
293
+ restoreNormalModeTools();
294
+ updateStatus(ctx);
295
+ persistState();
296
+ };
297
+ const exitPlanMode = (ctx: ExtensionContext): void => {
298
+ planModeEnabled = false;
299
+ footerError = undefined;
300
+ needsExitReminder = true;
301
+ restoreNormalModeTools();
302
+ ctx.ui.notify("Plan mode disabled. Full access restored.");
303
+ updateStatus(ctx);
304
+ persistState();
305
+ };
306
+ const activatePlanMode = (ctx: ExtensionContext, clearRunInfo: boolean): void => {
307
+ planModeEnabled = true;
308
+ footerError = undefined;
309
+ needsExitReminder = false;
310
+ planSlug = getPlanSlug();
311
+ lastReentryState = getPlan() !== null;
312
+ if (clearRunInfo) activeRunMoaInfo = undefined;
313
+ resetImplementationState();
314
+ enablePlanModeTools();
315
+ updateStatus(ctx);
316
+ persistState();
317
+ };
318
+ const clearCompletedPlan = async (ctx: ExtensionContext): Promise<void> => {
319
+ if (questionnaireBusy(ctx)) return;
320
+ const session = activeCancelSession;
321
+ if (session?.run) {
322
+ ctx.ui.notify("An MoA run is in progress — cancel it before clearing plan state.", "warning");
323
+ return;
324
+ }
325
+ if (session) {
326
+ ctx.ui.notify("Close the open plan prompt or picker before clearing plan state.", "warning");
327
+ return;
328
+ }
329
+ if (!planModeEnabled && getPlan() === null) {
330
+ ctx.ui.notify("No active or completed plan to clear.");
331
+ return;
332
+ }
333
+ if (ctx.hasUI) {
334
+ const confirmed = await ctx.ui.confirm(
335
+ "Clear completed plan?",
336
+ "The next /mf-plan starts a fresh planning round with a new plan file. The approved plan stays saved in .pi/mf-plan/ and can still be re-implemented with /mf-plan-implement.",
337
+ );
338
+ if (!confirmed) return;
339
+ }
340
+ if (planModeEnabled) exitPlanMode(ctx);
341
+ resetPlanSlug();
342
+ planSlug = undefined;
343
+ planRepoSlug = undefined;
344
+ lastReentryState = false;
345
+ activeRunMoaInfo = undefined;
346
+ resetImplementationState();
347
+ footerError = undefined;
348
+ updateStatus(ctx);
349
+ persistState();
350
+ ctx.ui.notify("Plan state cleared — the next /mf-plan starts a fresh planning round.");
351
+ };
352
+ const beginInteractive = (ctx: ExtensionContext): void => { activatePlanMode(ctx, false); };
353
+ const enterFromTool = async (ctx: ExtensionContext, prompt: string | undefined): Promise<boolean> => {
354
+ activatePlanMode(ctx, true);
355
+ if (prompt) await saveSubmittedPlanPrompt(ctx, prompt);
356
+ return lastReentryState;
357
+ };
358
+ async function saveSubmittedPlanPrompt(ctx: ExtensionContext, prompt: string): Promise<void> {
359
+ planRepoSlug = await summarizePlanPromptName(ctx, prompt);
360
+ persistState();
361
+ saveRepoPlanFile(prompt, ctx.cwd, planRepoSlug, "plan-prompt");
362
+ }
363
+ const saveApprovedPlanToRepo = (ctx: ExtensionContext, plan: string): void => {
364
+ if (planRepoSlug) saveRepoPlanFile(plan, ctx.cwd, planRepoSlug, "plan");
365
+ };
366
+ const moaRunHost: MoaRunHost = {
367
+ pi,
368
+ getPlanRepoSlug: () => planRepoSlug,
369
+ getActiveObserveSession: () => activeObserveSession,
370
+ setActiveObserveSession: (session) => { activeObserveSession = session; },
371
+ getActiveRunMoaInfo: () => activeRunMoaInfo,
372
+ setActiveRunMoaInfo: (info) => { activeRunMoaInfo = info; },
373
+ persistState,
374
+ currentThinkingLevel,
375
+ applyImplementingSelection: (ctx, selection, notice) => applyImplementingSelection(pi, ctx, selection, notice),
376
+ exitPlanMode,
377
+ saveApprovedPlanToRepo,
378
+ getImplementationHandoff: () => implementationHandoff,
379
+ setImplementationHandoff: (handoff) => {
380
+ implementationHandoff = handoff;
381
+ persistState();
382
+ },
383
+ markImplementationPending: (ctx) => {
384
+ footerError = undefined;
385
+ implementationPending = true;
386
+ implementationOutputTracker = new OutputActivityTracker();
387
+ resetImplementationCounters();
388
+ // Reconstruct the table for a resumed run that never adopted one (e.g.
389
+ // /mf-plan-implement after a restart). In-run paths already hold the
390
+ // adopted widget, so this only fires when none exists; single-model
391
+ // exit_plan_mode passes no ctx and has no MoA info, so it is skipped.
392
+ if (!activeProgressWidget && ctx && activeRunMoaInfo && implementationHandoff?.model) {
393
+ const widget = new MoaProgressWidget(ctx, (ref) => resolveContextWindow(ctx, ref), {}, planRepoSlug);
394
+ widget.setPhaseModels({
395
+ Plan: activeRunMoaInfo.proposers,
396
+ Synthesize: activeRunMoaInfo.synthesizer,
397
+ Implement: implementationHandoff.model,
398
+ Verify: implementationHandoff.verifier,
399
+ });
400
+ widget.switchToImplementing(implementationHandoff.model, "implementing plan…", implementationHandoff.thinking);
401
+ activeProgressWidget = widget;
402
+ }
403
+ if (ctx) updateStatus(ctx);
404
+ },
405
+ noteRunError: (ctx, message) => {
406
+ footerError = message;
407
+ updateStatus(ctx);
408
+ },
409
+ adoptProgressWidget: (widget) => {
410
+ if (activeProgressWidget && activeProgressWidget !== widget) activeProgressWidget.stopWidget();
411
+ activeProgressWidget = widget;
412
+ },
413
+ getActiveProgressWidget: () => activeProgressWidget,
414
+ getRunningProgressWidget: () => runningProgressWidget,
415
+ setRunningProgressWidget: (widget) => { runningProgressWidget = widget; },
416
+ stopActiveProgressWidget,
417
+ setActiveCancelSession: (session) => { activeCancelSession = session; },
418
+ };
419
+
420
+ return {
421
+ pi,
422
+ isEnabled: () => planModeEnabled,
423
+ currentThinkingLevel,
424
+ beginInteractive,
425
+ enterFromTool,
426
+ abortPlanMode,
427
+ exitPlanMode,
428
+ deactivatePlanOnlyTools,
429
+ clearCompletedPlan,
430
+ openCancelOverlayIfActive,
431
+ openObserveOverlayIfActive,
432
+ toggleLivePreview: (ctx: ExtensionContext): void => {
433
+ toggleLivePreviewWidget(ctx);
434
+ },
435
+ isAskUserQuestionActive: () => askUserQuestionTracker.isActive(),
436
+ questionnaireBusy,
437
+ getActiveCancelSession: () => activeCancelSession,
438
+ setActiveCancelSession: (session: CancelSession | undefined) => { activeCancelSession = session; },
439
+ getActiveObserveSession: () => activeObserveSession,
440
+ getLastReentryState: () => lastReentryState,
441
+ clearActiveRunMoaInfo: () => { activeRunMoaInfo = undefined; persistState(); },
442
+ setActiveRunMoaInfo: (info: MfPlanInfo | undefined) => { activeRunMoaInfo = info; persistState(); },
443
+ getActiveRunMoaInfo: () => activeRunMoaInfo,
444
+ getPlanRepoSlug: () => planRepoSlug,
445
+ setPlanRepoSlug: (slug: string) => { planRepoSlug = slug; },
446
+ getImplementationHandoff: moaRunHost.getImplementationHandoff,
447
+ setImplementationHandoff: moaRunHost.setImplementationHandoff,
448
+ markImplementationPending: moaRunHost.markImplementationPending,
449
+ isImplementationPending: () => implementationPending,
450
+ persistState,
451
+ saveSubmittedPlanPrompt,
452
+ saveApprovedPlanToRepo,
453
+ moaRunHost,
454
+ applyImplementingSelection: moaRunHost.applyImplementingSelection,
455
+ onAgentStart: (_event: AgentStartEvent, ctx: ExtensionContext) => {
456
+ footerError = undefined;
457
+ updateStatus(ctx);
458
+ },
459
+ onMessageStart: (event: MessageStartEvent) => {
460
+ if (!implementationPending || !activeProgressWidget) return;
461
+ if (event.message.role === "assistant") {
462
+ implementationOutputTracker?.messageStart(event.message);
463
+ implementationPartial.start(event.message);
464
+ }
465
+ },
466
+ onMessageUpdate: (event: MessageUpdateEvent) => {
467
+ if (!implementationPending || !activeProgressWidget) return;
468
+ implementationOutputTracker?.messageUpdate(event.assistantMessageEvent);
469
+ const snapshot = implementationOutputTracker?.snapshot();
470
+ if (snapshot) activeProgressWidget.updateRoleOutput("Implement", snapshot.tokens, snapshot.revision);
471
+ implementationPartial.apply(event.assistantMessageEvent);
472
+ activeProgressWidget.updateRoleTranscript("Implement", implementationMessages, implementationPartial.snapshot());
473
+ },
474
+ onToolExecutionStart: (event: ToolExecutionStartEvent) => {
475
+ if (!implementationPending || !activeProgressWidget) return;
476
+ implementationToolCalls++;
477
+ activeProgressWidget.updateRoleActivity("Implement", formatToolActivity(event.toolName, event.args));
478
+ },
479
+ onMessageEnd: (event: MessageEndEvent, ctx: ExtensionContext) => {
480
+ if (!implementationPending) return;
481
+ const msg = event.message as { role?: string; stopReason?: string; content?: unknown; usage?: AssistantUsageLike };
482
+ if (msg.role === "assistant") {
483
+ lastImplementationStopReason = msg.stopReason;
484
+ const text = extractAssistantText(msg.content);
485
+ if (text) lastImplementationReport = text.slice(-IMPLEMENTATION_REPORT_MAX_CHARS);
486
+
487
+ implementationOutputTracker?.messageEnd(event.message);
488
+ // Exact usage supersedes the streamed word estimate; the revision bump
489
+ // lets the meter reset rather than metering the correction as output.
490
+ const output = implementationOutputTracker?.snapshot();
491
+ if (output && activeProgressWidget) activeProgressWidget.updateRoleOutput("Implement", output.tokens, output.revision);
492
+ if (msg.usage) {
493
+ implementationUsage ??= { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 };
494
+ implementationUsage.input += nonnegative(msg.usage.input);
495
+ implementationUsage.output += nonnegative(msg.usage.output);
496
+ implementationUsage.cacheRead += nonnegative(msg.usage.cacheRead);
497
+ implementationUsage.cacheWrite += nonnegative(msg.usage.cacheWrite);
498
+ implementationUsage.cacheWrite1h += nonnegative(msg.usage.cacheWrite1h);
499
+ implementationUsage.cost += nonnegative(msg.usage.cost?.total);
500
+ if (typeof msg.usage.totalTokens === "number" && msg.usage.totalTokens >= 0) {
501
+ implementationContextTokens = msg.usage.totalTokens;
502
+ }
503
+ }
504
+ implementationTurns++;
505
+ if (activeProgressWidget && implementationHandoff?.model) {
506
+ activeProgressWidget.updateRoleUsage(
507
+ "Implement",
508
+ implementationContextTokens,
509
+ implementationTurns,
510
+ implementationToolCalls,
511
+ resolveModelCost(ctx, implementationHandoff.model, implementationUsage ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cacheWrite1h: 0, cost: 0, turns: 0, toolCalls: 0 }),
512
+ );
513
+ }
514
+ implementationPartial.clear();
515
+ }
516
+ if (event.message.role === "assistant" || event.message.role === "toolResult") {
517
+ implementationMessages.push(event.message);
518
+ implementationMessages = implementationMessages.slice(-IMPLEMENTATION_TRANSCRIPT_MESSAGE_LIMIT);
519
+ activeProgressWidget?.updateRoleTranscript("Implement", implementationMessages);
520
+ }
521
+ },
522
+ onAgentSettled: (_event: AgentSettledEvent, ctx: ExtensionContext) => {
523
+ updateStatus(ctx);
524
+ if (!implementationPending) return;
525
+ implementationPending = false;
526
+ const stopReason = lastImplementationStopReason;
527
+ const report = lastImplementationReport;
528
+ lastImplementationStopReason = undefined;
529
+ lastImplementationReport = undefined;
530
+
531
+ // Settle the Implement row before any retry/verify routing. Aborted
532
+ // runs stop the table outright (no verification follows).
533
+ if (activeProgressWidget) {
534
+ if (implementationTurnFailed(stopReason)) {
535
+ activeProgressWidget.settleRoleRow("Implement", "error");
536
+ } else if (stopReason === "aborted") {
537
+ activeProgressWidget.settleRoleRow("Implement", "cancelled");
538
+ stopActiveProgressWidget();
539
+ } else {
540
+ activeProgressWidget.settleRoleRow("Implement", "done");
541
+ }
542
+ }
543
+
544
+ // Both async flows below outlive this handler and may reject after a
545
+ // /reload (or session swap) has made `ctx` stale. pi's ctx getters then
546
+ // throw synchronously, so the rejection handler must not touch ctx.ui
547
+ // unguarded — an exception inside .catch becomes an unhandled rejection
548
+ // that takes the whole process down.
549
+ const reportFlowFailure = (flow: string) => (err: unknown) => {
550
+ const detail = err instanceof Error ? err.message : String(err);
551
+ try {
552
+ footerError = `${flow} could not run: ${detail}`;
553
+ updateStatus(ctx);
554
+ ctx.ui.notify(`${flow} could not run: ${detail}`, "error");
555
+ } catch {
556
+ // ctx is stale (replaced/reloaded); nothing left to notify against.
557
+ }
558
+ };
559
+ if (implementationTurnFailed(stopReason) && implementationHandoff) {
560
+ void runImplementationRetryFlow(ctx, moaRunHost, implementationHandoff)
561
+ .catch(reportFlowFailure("Implementation retry"));
562
+ return;
563
+ }
564
+ // A user-cancelled implementation must NOT be verified. implementationTurnFailed
565
+ // returns false for "aborted" (so the retry flow above is skipped), so this
566
+ // explicit guard is required before the verification branch.
567
+ if (stopReason === "aborted") return;
568
+ if (!implementationHandoff) return;
569
+ const verifier = implementationHandoff.verifier ?? loadMoaConfig().verifier;
570
+ if (verifier) {
571
+ void runImplementationVerification(ctx, moaRunHost, { ...implementationHandoff, verifier }, report)
572
+ .catch(reportFlowFailure("Verification"));
573
+ }
574
+ },
575
+ onContext: async (event: { messages: AgentMessage[] }) => {
576
+ if (planModeEnabled) return;
577
+ return { details: undefined, messages: event.messages.filter((message) => {
578
+ const msg = message as AgentMessage & { customType?: string };
579
+ if (msg.customType === PLAN_MODE_CONTEXT_TYPE || msg.customType === PLAN_EXIT_CONTEXT_TYPE) return false;
580
+ if (msg.role !== "user") return true;
581
+ if (typeof msg.content === "string") return !msg.content.startsWith("[PLAN MODE ACTIVE]") && !msg.content.startsWith("[PLAN MODE RE-ENTRY]");
582
+ if (Array.isArray(msg.content)) return !msg.content.some((part) => part.type === "text" && ((part as TextContent).text?.startsWith("[PLAN MODE ACTIVE]") || (part as TextContent).text?.startsWith("[PLAN MODE RE-ENTRY]")));
583
+ return true;
584
+ }) };
585
+ },
586
+ onBeforeAgentStart: async () => {
587
+ if (needsExitReminder) {
588
+ needsExitReminder = false;
589
+ persistState();
590
+ return { message: { customType: PLAN_EXIT_CONTEXT_TYPE, content: buildPlanModeExitInstructions(), display: false } };
591
+ }
592
+ if (!planModeEnabled) {
593
+ deactivatePlanOnlyTools();
594
+ return;
595
+ }
596
+ const instructions = lastReentryState ? buildPlanModeReentryInstructions() : buildPlanModeInstructions(isAskUserQuestionInstalled(pi));
597
+ if (lastReentryState) lastReentryState = false;
598
+ return { message: { customType: PLAN_MODE_CONTEXT_TYPE, content: instructions, display: false } };
599
+ },
600
+ onSessionStart: async (_event: unknown, ctx: ExtensionContext) => {
601
+ unsubscribeLivePreviewInput?.();
602
+ unsubscribeLivePreviewInput = undefined;
603
+ installShippedAgents();
604
+ planModeEnabled = false;
605
+ toolsBeforePlanMode = undefined;
606
+ restoreReadOnlyProviderEnv();
607
+ needsExitReminder = false;
608
+ planSlug = undefined;
609
+ planRepoSlug = undefined;
610
+ lastReentryState = false;
611
+ activeRunMoaInfo = undefined;
612
+ activeCancelSession = undefined;
613
+ activeObserveSession = undefined;
614
+ implementationHandoff = undefined;
615
+ footerError = undefined;
616
+ lastRenderedFooter = undefined;
617
+ lastFooterCtx = undefined;
618
+ resetImplementationState();
619
+ // Clear any blocked flag left by a questionnaire that died without its
620
+ // finally (a fresh session must not inherit a stuck "answer first" gate),
621
+ // then re-subscribe (retaining the handle means no stacked handlers).
622
+ askUserQuestionTracker.reset();
623
+ askUserQuestionTracker.ensureSubscribed();
624
+ persistence.reset();
625
+ resetPlanSlug();
626
+ if (pi.getFlag("mf-plan") === true) planModeEnabled = true;
627
+ const restored = latestPlanModeStateEntry(ctx.sessionManager.getEntries());
628
+ if (restored) {
629
+ planModeEnabled = restored.enabled ?? planModeEnabled;
630
+ needsExitReminder = restored.needsExitReminder ?? false;
631
+ if (isValidPlanSlug(restored.slug) && setPlanSlug(restored.slug)) planSlug = restored.slug;
632
+ planRepoSlug = isValidPlanSlug(restored.repoPlanSlug) ? restored.repoPlanSlug : undefined;
633
+ activeRunMoaInfo = validateMfPlanInfo(restored.moaInfo);
634
+ implementationHandoff = validateImplementationHandoff(restored.implementationHandoff);
635
+ }
636
+ if (planModeEnabled) {
637
+ lastReentryState = getPlan() !== null;
638
+ enablePlanModeTools();
639
+ } else {
640
+ deactivatePlanOnlyTools();
641
+ }
642
+ updateStatus(ctx);
643
+ startFooterTicker(ctx);
644
+ if (ctx.mode === "tui" && typeof ctx.ui.onTerminalInput === "function") {
645
+ unsubscribeLivePreviewInput = ctx.ui.onTerminalInput((data) => {
646
+ if (process.env.MOA_PLAN_DEBUG_KEYS === "1") ctx.ui.notify(`MoA terminal input: ${JSON.stringify(data)}`, "info");
647
+ if (!matchesFunctionKeyPress(data, Key.f2)) return undefined;
648
+ if (questionnaireBusy(ctx)) return undefined;
649
+ if (activeCancelSession?.overlayOpen || activeObserveSession?.overlayOpen) return undefined;
650
+ return toggleLivePreviewWidget(ctx) ? { consume: true } : undefined;
651
+ });
652
+ }
653
+ },
654
+ onSessionShutdown: (_event: { reason: string }) => {
655
+ implementationPending = false;
656
+ lastImplementationStopReason = undefined;
657
+ lastImplementationReport = undefined;
658
+ resetImplementationTranscript();
659
+ stopActiveProgressWidget();
660
+ runningProgressWidget = undefined;
661
+ restoreReadOnlyProviderEnv();
662
+ cleanupTrackedProcesses();
663
+ unsubscribeLivePreviewInput?.();
664
+ unsubscribeLivePreviewInput = undefined;
665
+ stopFooterTicker();
666
+ footerError = undefined;
667
+ lastFooterCtx = undefined;
668
+ lastRenderedFooter = undefined;
669
+ },
670
+ };
671
+ }
672
+
673
+ export type PlanModeController = ReturnType<typeof createPlanModeController>;