@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,165 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
3
+ import { Type } from "typebox";
4
+
5
+ import { installShippedAgents } from "../../agents/authoritative.ts";
6
+ import { loadMoaConfig, saveMoaConfig } from "../../config/settings.ts";
7
+ import { runMoaOrchestration } from "../../moa/orchestration.ts";
8
+ import type { MfPlanInfo } from "../../moa/planInfo.ts";
9
+ import type { MoaRunHost } from "../../moa/runContext.ts";
10
+ import type { CancelSession } from "../../runtime/cancelRun.ts";
11
+ import { modelRefLabel, TRIGGER_TURN, type ModelRef, type ThinkingLevel } from "../../shared/modelRefs.ts";
12
+ import { showMoaModelPicker, showImplementingModelPicker } from "../../ui/moaModelPicker.ts";
13
+ import { showMoaSetup } from "../../ui/moaSetupOverlay.ts";
14
+ import { showPromptEditor } from "../../ui/promptEditor.ts";
15
+ import { getPlan } from "../planFile.ts";
16
+ import { isAskUserQuestionInstalled } from "../askUserQuestion.ts";
17
+ import { buildPlanModeInstructions, buildPlanModeReentryInstructions } from "../instructions.ts";
18
+
19
+ export interface InteractivePlanModeHost {
20
+ beginInteractive(ctx: ExtensionContext): void;
21
+ abortPlanMode(ctx: ExtensionContext): void;
22
+ currentThinkingLevel(): ThinkingLevel;
23
+ getLastReentryState(): boolean;
24
+ getActiveObserveSession(): { overlayOpen: boolean } | undefined;
25
+ openCancelOverlayIfActive(ctx: ExtensionContext): boolean;
26
+ setActiveCancelSession(session: CancelSession | undefined): void;
27
+ saveSubmittedPlanPrompt(ctx: ExtensionContext, prompt: string): Promise<void>;
28
+ clearActiveRunMoaInfo(): void;
29
+ setActiveRunMoaInfo(info: MfPlanInfo | undefined): void;
30
+ applyImplementingSelection(ctx: ExtensionContext, selection: { ref: ModelRef; thinking: ThinkingLevel }, notice: string): Promise<void>;
31
+ moaRunHost: MoaRunHost;
32
+ }
33
+
34
+ export async function runInteractivePlanMode(
35
+ pi: ExtensionAPI,
36
+ host: InteractivePlanModeHost,
37
+ ctx: ExtensionContext,
38
+ initialPrompt?: string,
39
+ ): Promise<void> {
40
+ host.beginInteractive(ctx);
41
+ if (!ctx.hasUI) {
42
+ ctx.ui.notify("Plan mode enabled. Read-only exploration with parallel subagents.");
43
+ return;
44
+ }
45
+ if (!loadMoaConfig().agentDefaultsConfigured) {
46
+ installShippedAgents();
47
+ if (!await showMoaSetup(ctx, host.currentThinkingLevel(), { firstRun: true })) {
48
+ host.abortPlanMode(ctx);
49
+ ctx.ui.notify("Setup cancelled — plan mode not started. Run /mf-plan-settings to configure agents and rosters.", "warning");
50
+ return;
51
+ }
52
+ }
53
+
54
+ // While the plan-prompt flow is live, ESC opens the cancel overlay —
55
+ // but only while subagent processes are actually in flight
56
+ // (session.run is set during MoA fan-out/synthesis).
57
+ // Whenever nothing is running the listener passes ESC through, so the
58
+ // editor, the model pickers and the review overlay keep their normal
59
+ // ESC behavior.
60
+ const session: CancelSession = { title: "Plan agents", run: undefined, overlayOpen: false };
61
+ host.setActiveCancelSession(session);
62
+ const unsubscribeEsc = ctx.mode === "tui"
63
+ ? ctx.ui.onTerminalInput((data) => {
64
+ // Terminal input listeners run ahead of pi's key-release filter, so under
65
+ // the Kitty keyboard protocol the ESC *release* arrives here a tick after
66
+ // the press closed an overlay and cleared `overlayOpen` — reopening it
67
+ // instantly. Only a key press may open the overlay.
68
+ if (isKeyRelease(data) || !matchesKey(data, Key.escape)) return undefined;
69
+ if (host.getActiveObserveSession()?.overlayOpen) return undefined;
70
+ if (session.overlayOpen) return undefined;
71
+ if (!session.run) return undefined;
72
+ return host.openCancelOverlayIfActive(ctx) ? { consume: true } : undefined;
73
+ })
74
+ : undefined;
75
+ try {
76
+ let prefill = host.getLastReentryState() ? getPlan() ?? "" : "";
77
+ let skipEditorOnce = Boolean(initialPrompt?.trim());
78
+ while (true) {
79
+ let prompt: string | undefined;
80
+ if (skipEditorOnce) {
81
+ prompt = initialPrompt!.trim();
82
+ skipEditorOnce = false;
83
+ initialPrompt = undefined;
84
+ } else prompt = await showPromptEditor(ctx, "Plan Mode — describe what you want to plan", prefill);
85
+ if (!prompt || !prompt.trim()) {
86
+ host.abortPlanMode(ctx);
87
+ ctx.ui.notify("Plan mode cancelled.");
88
+ return;
89
+ }
90
+ const trimmedPrompt = prompt.trim();
91
+ prefill = trimmedPrompt;
92
+ await host.saveSubmittedPlanPrompt(ctx, trimmedPrompt);
93
+ const pickerResult = await showMoaModelPicker(ctx, host.currentThinkingLevel());
94
+ if (!pickerResult || pickerResult.mode === "single") {
95
+ host.clearActiveRunMoaInfo();
96
+ if (pickerResult) {
97
+ const selection = await showImplementingModelPicker(ctx, host.currentThinkingLevel(), "Single model — choose the model and thinking level");
98
+ if (selection) await host.applyImplementingSelection(ctx, selection, `Using ${modelRefLabel(selection.ref)} for this plan.`);
99
+ }
100
+ ctx.ui.notify("Plan mode enabled. Processing your prompt...");
101
+ pi.sendUserMessage(trimmedPrompt, TRIGGER_TURN);
102
+ return;
103
+ }
104
+ const settings = loadMoaConfig();
105
+ const { proposers, synthesizer, implementer, verifier, proposerThinking, synthesizerThinking, implementerThinking, verifierThinking, thinkingSelections } = pickerResult;
106
+ saveMoaConfig({ ...settings, mode: "moa", proposers, synthesizer, implementer, verifier, thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections } });
107
+ host.setActiveRunMoaInfo({ proposers, synthesizer });
108
+ const outcome = await runMoaOrchestration(host.moaRunHost, ctx, trimmedPrompt, proposers, synthesizer, proposerThinking, synthesizerThinking, session, { implementer, implementerThinking, verifier, verifierThinking });
109
+ if (outcome === "cancelled") { session.closeOverlay?.(); continue; }
110
+ return;
111
+ }
112
+ } finally {
113
+ unsubscribeEsc?.();
114
+ host.setActiveCancelSession(undefined);
115
+ }
116
+ }
117
+
118
+ export interface EnterPlanModeHost {
119
+ isEnabled(): boolean;
120
+ enterFromTool(ctx: ExtensionContext, prompt: string | undefined): Promise<boolean>;
121
+ }
122
+
123
+ export function registerEnterPlanModeTool(pi: ExtensionAPI, host: EnterPlanModeHost): void {
124
+ pi.registerTool({
125
+ name: "enter_plan_mode",
126
+ label: "Enter Plan Mode",
127
+ description: [
128
+ "Enter plan mode to design an implementation before writing code.",
129
+ "Switches the session to read-only tools and runs the single-model planning workflow on the current session model.",
130
+ "Write the plan with write_plan, then call exit_plan_mode for user approval.",
131
+ "Use when the user asks for a plan, design, or implementation approach.",
132
+ "Mixture-of-Agents planning is not available through this tool — the user starts that with /mf-plan.",
133
+ ].join(" "),
134
+ parameters: Type.Object({
135
+ plan_prompt: Type.Optional(
136
+ Type.String({ description: "Short description of what is being planned (used to name the plan file)" }),
137
+ ),
138
+ }),
139
+ // enter_plan_mode is paired with a same-message ask_user_question only in
140
+ // the single-model path; marking it sequential means the questionnaire
141
+ // resolves first, so mf-plan's own overlays never draw over it.
142
+ executionMode: "sequential",
143
+
144
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
145
+ if (host.isEnabled()) {
146
+ return {
147
+ details: undefined,
148
+ content: [{ type: "text", text: "Already in plan mode. Continue planning and call exit_plan_mode when your plan is ready." }],
149
+ isError: true,
150
+ };
151
+ }
152
+
153
+ const reentry = await host.enterFromTool(ctx, params.plan_prompt?.trim());
154
+ ctx.ui.notify("Plan mode enabled by the agent (single model). Read-only until the plan is approved.");
155
+ const instructions = reentry ? buildPlanModeReentryInstructions() : buildPlanModeInstructions(isAskUserQuestionInstalled(pi));
156
+ return {
157
+ details: undefined,
158
+ content: [{
159
+ type: "text",
160
+ text: `Plan mode enabled (single model — the current session model). Mutating tools are disabled until your plan is approved via exit_plan_mode.\n\n${instructions}`,
161
+ }],
162
+ };
163
+ },
164
+ });
165
+ }
@@ -0,0 +1,159 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+
4
+ import { summarizePlanPromptName } from "../../config/planName.ts";
5
+ import { buildImplementationKickoffMessage } from "../../moa/implementationRetry.ts";
6
+ import { stripSynthSections } from "../../moa/verdicts.ts";
7
+ import { FOLLOW_UP } from "../../shared/modelRefs.ts";
8
+ import type { MfPlanInfo, PlanReviewDecision } from "../../moa/planInfo.ts";
9
+ import { showPlanReview } from "../../ui/planReviewOverlay.ts";
10
+ import { ASK_USER_QUESTION_PENDING_MESSAGE } from "../askUserQuestion.ts";
11
+ import { getPlan, getPlanFilePath, saveRepoPlanFile, writePlan } from "../planFile.ts";
12
+
13
+ import type { ImplementationHandoff } from "../../moa/implementationRetry.ts";
14
+
15
+ export interface ExitPlanModeHost {
16
+ pi: ExtensionAPI;
17
+ isEnabled(): boolean;
18
+ /** True while an ask_user_question questionnaire is waiting for the user. */
19
+ isAskUserQuestionActive(): boolean;
20
+ exitPlanMode(ctx: ExtensionContext): void;
21
+ getPlanRepoSlug(): string | undefined;
22
+ setPlanRepoSlug(slug: string): void;
23
+ persistState(): void;
24
+ getActiveRunMoaInfo(): MfPlanInfo | undefined;
25
+ saveApprovedPlanToRepo(ctx: ExtensionContext, plan: string): void;
26
+ getImplementationHandoff(): ImplementationHandoff | undefined;
27
+ setImplementationHandoff(handoff: ImplementationHandoff | undefined): void;
28
+ markImplementationPending(): void;
29
+ }
30
+
31
+ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHost): void {
32
+ pi.registerTool({
33
+ name: "exit_plan_mode",
34
+ label: "Exit Plan Mode",
35
+ description: "Use when you are in plan mode and have finished writing your plan to the plan file and are ready for user approval. Reads the plan from the plan file — does NOT take plan content as a parameter. Only use for tasks that require writing code; not for pure research.",
36
+ parameters: Type.Object({}),
37
+ // exit_plan_mode opens a model/UI surface the model may not have asked
38
+ // the user about yet; marking it sequential means a same-message
39
+ // ask_user_question resolves first, so approval never lands on unread
40
+ // answers.
41
+ executionMode: "sequential",
42
+
43
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
44
+ if (!host.isEnabled()) {
45
+ return {
46
+ details: undefined,
47
+ content: [{ type: "text", text: "You are not in plan mode. This tool is only for exiting plan mode after writing a plan. If your plan was already approved, continue with implementation." }],
48
+ isError: true,
49
+ };
50
+ }
51
+
52
+ const plan = getPlan();
53
+ const filePath = getPlanFilePath();
54
+
55
+ if (!plan || plan.trim() === "") {
56
+ return {
57
+ details: undefined,
58
+ content: [{ type: "text", text: `No plan content found at ${filePath}. Please write your plan to this file using the write_plan tool before calling exit_plan_mode.` }],
59
+ isError: true,
60
+ };
61
+ }
62
+
63
+ // A waiting questionnaire must be answered before the plan is approved.
64
+ // Placed before summarization and the UI branch so it never triggers a
65
+ // plan-name model call and cannot fire in headless runs (rpiv strips its
66
+ // tool there, so the flag stays false).
67
+ if (host.isAskUserQuestionActive()) {
68
+ return { details: undefined, content: [{ type: "text", text: ASK_USER_QUESTION_PENDING_MESSAGE }], isError: true };
69
+ }
70
+
71
+ // Non-interactive mode: auto-approve requires explicit opt-in
72
+ if (!ctx.hasUI) {
73
+ if (process.env.MOA_PLAN_AUTO_APPROVE !== "1") {
74
+ return {
75
+ details: undefined,
76
+ content: [{ type: "text", text: "Auto-approve is disabled in headless mode. Set MOA_PLAN_AUTO_APPROVE=1 to enable, or refine the plan further and try again." }],
77
+ isError: true,
78
+ };
79
+ }
80
+ host.exitPlanMode(ctx);
81
+ host.setImplementationHandoff({
82
+ plan,
83
+ planFilePath: filePath,
84
+ repoPlanSlug: host.getPlanRepoSlug(),
85
+ model: ctx.model,
86
+ timestamp: Date.now(),
87
+ });
88
+ host.markImplementationPending();
89
+ return {
90
+ details: undefined,
91
+ content: [
92
+ {
93
+ type: "text",
94
+ text: buildImplementationKickoffMessage(plan, filePath),
95
+ },
96
+ ],
97
+ };
98
+ }
99
+
100
+ if (!host.getPlanRepoSlug()) {
101
+ host.setPlanRepoSlug(await summarizePlanPromptName(ctx, plan));
102
+ host.persistState();
103
+ }
104
+
105
+ // Interactive TUI: show the proposed plan in a scrollable review overlay.
106
+ // RPC has UI primitives but no terminal custom component, so keep the select fallback there.
107
+ let currentPlan = plan;
108
+ while (true) {
109
+ let decision: PlanReviewDecision;
110
+ if (ctx.mode === "tui") {
111
+ decision = await showPlanReview(ctx, currentPlan, host.getActiveRunMoaInfo(), host.getPlanRepoSlug());
112
+ } else {
113
+ const choice = await ctx.ui.select("Exit plan mode?", ["Approve — start implementing", "Keep planning", "Edit plan"]);
114
+ decision = choice?.startsWith("Approve") ? "approve" : choice?.startsWith("Edit") ? "edit" : "keep";
115
+ }
116
+
117
+ if (decision === "edit") {
118
+ const edited = await ctx.ui.editor("Edit Plan", currentPlan);
119
+ if (edited?.trim()) {
120
+ currentPlan = stripSynthSections(edited.trim());
121
+ writePlan(currentPlan);
122
+ const repoPlanSlug = host.getPlanRepoSlug();
123
+ if (repoPlanSlug) saveRepoPlanFile(currentPlan, ctx.cwd, repoPlanSlug, "plan");
124
+ }
125
+ continue;
126
+ }
127
+ if (decision === "approve") {
128
+ if (ctx.mode !== "tui") host.saveApprovedPlanToRepo(ctx, currentPlan);
129
+ host.exitPlanMode(ctx);
130
+ host.setImplementationHandoff({
131
+ plan: currentPlan,
132
+ planFilePath: filePath,
133
+ repoPlanSlug: host.getPlanRepoSlug(),
134
+ model: ctx.model,
135
+ timestamp: Date.now(),
136
+ });
137
+ host.markImplementationPending();
138
+ // Deliver the go-ahead as a follow-up (a fresh turn) rather than in this
139
+ // tool result. An agentic-bridge query is created read-only for the whole
140
+ // planning turn; returning "start coding" here just resumes that read-only
141
+ // query. Instead we tell the model to STOP so the read-only query ends,
142
+ // then the queued follow-up runs as a new, full-access query that re-reads
143
+ // the restored env. (terminate:true is worse: the bridge replays the
144
+ // trailing tool result as a continuation that inherits the read-only tools.)
145
+ host.pi.sendUserMessage(
146
+ buildImplementationKickoffMessage(currentPlan, filePath),
147
+ FOLLOW_UP,
148
+ );
149
+ return {
150
+ details: undefined,
151
+ content: [{ type: "text", text: "Plan approved and saved. Plan mode has exited. Stop here — do not write files, run commands, or call any tools in this turn. A follow-up message will tell you to begin implementation." }],
152
+ };
153
+ }
154
+
155
+ return { content: [{ type: "text", text: "User wants to keep refining the plan. Continue working on the plan file and call exit_plan_mode when ready." }], details: undefined };
156
+ }
157
+ },
158
+ });
159
+ }
@@ -0,0 +1,311 @@
1
+ import { StringEnum } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
4
+ import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
5
+ import { Type } from "typebox";
6
+ import * as path from "node:path";
7
+
8
+ import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../../agents/authoritative.ts";
9
+ import { discoverAgents, parseAgentFile } from "../../agents/discovery.ts";
10
+ import { modelExtensionOptions } from "../../moa/modelRuntime.ts";
11
+ import { CancelRun, type CancelRowExtras, type CancelSession } from "../../runtime/cancelRun.ts";
12
+ import { MutationTripwire, formatMutationWarning } from "../../runtime/mutationTripwire.ts";
13
+ import { getFinalOutput, getResultOutput, isFailedResult, truncateOutput } from "../../runtime/results.ts";
14
+ import { runParallelAgents, runSingleAgent } from "../../runtime/runner.ts";
15
+ import { parseRef as parseModelRefLabel, type ThinkingLevel } from "../../shared/modelRefs.ts";
16
+ import { activityLoopCount } from "../../ui/agentStatus.ts";
17
+ import { showCancelOverlay } from "../../ui/cancelOverlay.ts";
18
+ import { PLAN_SUBAGENT_NAMES } from "./shared.ts";
19
+
20
+ const CANCEL_HINT_WIDGET_KEY = "mf-plan-cancel";
21
+
22
+ /** Mount the F4 cancel hint above the editor while subagents are in flight; pass undefined to clear. */
23
+ function setCancelHint(ctx: ExtensionContext, text: string | undefined): void {
24
+ ctx.ui.setWidget(
25
+ CANCEL_HINT_WIDGET_KEY,
26
+ text ? [ctx.ui.theme.fg("dim", text)] : undefined,
27
+ { placement: "aboveEditor" },
28
+ );
29
+ }
30
+
31
+ export interface MfPlanSubagentHost {
32
+ isEnabled(): boolean;
33
+ /** True while an ask_user_question questionnaire is waiting for the user. */
34
+ isAskUserQuestionActive(): boolean;
35
+ currentThinkingLevel(): ThinkingLevel;
36
+ getActiveCancelSession(): CancelSession | undefined;
37
+ setActiveCancelSession(session: CancelSession | undefined): void;
38
+ }
39
+
40
+ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagentHost): void {
41
+ pi.registerTool({
42
+ name: "mf_plan_subagent",
43
+ label: "Moa Plan Subagent",
44
+ description: [
45
+ "ONLY usable inside /mf-plan plan mode — errors in any other context.",
46
+ "For general-purpose delegation outside plan mode, use the subagent tool instead.",
47
+ "Delegate exploration and planning tasks to specialized subagents with isolated context.",
48
+ "Modes: single (agent + task), parallel (tasks array).",
49
+ "Agents: moa-explore (fast codebase recon), mf-plan (implementation planning).",
50
+ `Default scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
51
+ ].join(" "),
52
+
53
+ // mf_plan_subagent can share a message with ask_user_question; marking it
54
+ // sequential means the questionnaire resolves first so its overlay never
55
+ // draws over a live questionnaire.
56
+ executionMode: "sequential",
57
+ parameters: Type.Object({
58
+ agent: Type.Optional(Type.String({ description: "Name of the agent to invoke (for single mode)" })),
59
+ task: Type.Optional(Type.String({ description: "Task to delegate (for single mode)" })),
60
+ tasks: Type.Optional(
61
+ Type.Array(
62
+ Type.Object({
63
+ agent: Type.String({ description: "Name of the agent to invoke" }),
64
+ task: Type.String({ description: "Task to delegate to the agent" }),
65
+ }),
66
+ { description: "Array of {agent, task} for parallel execution" },
67
+ ),
68
+ ),
69
+ agentScope: Type.Optional(
70
+ StringEnum(["user", "project", "both"] as const, {
71
+ description: 'Which agent directories to use. Default: "user".',
72
+ default: "user",
73
+ }),
74
+ ),
75
+ }),
76
+
77
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
78
+ if (!host.isEnabled()) {
79
+ return {
80
+ details: undefined,
81
+ content: [{ type: "text", text: "Error: mf_plan_subagent is only available in plan mode." }],
82
+ isError: true,
83
+ };
84
+ }
85
+
86
+ const requestedAgents = params.tasks?.length
87
+ ? params.tasks.map((task) => task.agent)
88
+ : params.agent ? [params.agent] : [];
89
+ const unsupportedAgent = requestedAgents.find((name) => !PLAN_SUBAGENT_NAMES.has(name));
90
+ if (unsupportedAgent) {
91
+ return {
92
+ details: undefined,
93
+ content: [{ type: "text", text: `Agent "${unsupportedAgent}" is not allowed in plan mode. Use only moa-explore or mf-plan.` }],
94
+ isError: true,
95
+ };
96
+ }
97
+
98
+ const agentScope = (params.agentScope ?? "user") as "user" | "project" | "both";
99
+ // Defensive re-install (matches runMoaOrchestration): guarantees
100
+ // moa-explore.md/mf-plan.md exist on disk even right after an in-session update.
101
+ installShippedAgents();
102
+ const discovery = discoverAgents(ctx.cwd, agentScope);
103
+ const agents = withAuthoritativeMoaAgents(discovery.agents, shippedAgentsDir());
104
+
105
+ // Guard moa-explore and mf-plan from repo-discoverable overrides:
106
+ // a committed agents/moa-explore.md can carry a malicious prompt.
107
+ // User-installed copies (source: "user") are untouched.
108
+ for (const name of PLAN_SUBAGENT_NAMES) {
109
+ const index = agents.findIndex((a) => a.name === name);
110
+ if (index >= 0 && agents[index]!.source === "project") {
111
+ const shipped = parseAgentFile(path.join(shippedAgentsDir(), `${name}.md`), "user");
112
+ if (shipped) agents[index] = shipped;
113
+ else agents.splice(index, 1);
114
+ }
115
+ }
116
+
117
+ // mf-plan follows the session model the user picked (both the
118
+ // single-model picker and the MoA implementing picker route through
119
+ // pi.setModel), so planning quality tracks that choice. Frontmatter is
120
+ // only a fallback; moa-explore stays on its own configured model.
121
+ const sessionModel = ctx.model;
122
+ const planOverride = sessionModel
123
+ ? { model: `${sessionModel.provider}/${sessionModel.id}`, thinking: host.currentThinkingLevel() }
124
+ : undefined;
125
+ const overrideFor = (agentName: string) => (agentName === "mf-plan" ? planOverride : undefined);
126
+
127
+ // An agent whose effective model belongs to an extension-registered
128
+ // provider (claude-bridge, cursor-bridge) cannot resolve in a child that
129
+ // spawns with --no-extensions. Same opt-in the MoA fan-out already does;
130
+ // built-in and bare ids fall through to no extension loading.
131
+ const agentExtensionOptions = (agentName: string): { loadExtensions?: boolean; extensionPath?: string } => {
132
+ const model = overrideFor(agentName)?.model ?? agents.find((agent) => agent.name === agentName)?.model;
133
+ return model ? modelExtensionOptions(ctx, parseModelRefLabel(model)) : {};
134
+ };
135
+
136
+ // Same cooperative-boundary tripwire as the MoA orchestration: explore/
137
+ // plan children are spawned read-only, but agentic provider bridges can
138
+ // bypass pi's tool loop — detect and surface any working-tree change.
139
+ const tripwire = new MutationTripwire();
140
+ await tripwire.arm(ctx.cwd);
141
+ const warnIfMutated = async (): Promise<void> => {
142
+ const changed = await tripwire.check(ctx.cwd);
143
+ if (changed.length > 0) ctx.ui.notify(formatMutationWarning("plan-mode subagents", changed), "error");
144
+ };
145
+
146
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
147
+ const hasSingle = Boolean(params.agent && params.task);
148
+
149
+ if (hasTasks) {
150
+ // Parallel mode — each task gets its own abort signal (combined
151
+ // with pi's turn signal) so F4 can kill one stuck agent
152
+ // while its siblings keep working.
153
+ const tasks = params.tasks!;
154
+ const run = new CancelRun();
155
+ const extras: CancelRowExtras[] = tasks.map(() => ({}));
156
+ const histories: string[][] = tasks.map(() => []);
157
+ const toolSession: CancelSession = {
158
+ title: "Plan subagents",
159
+ run,
160
+ getExtras: (i) => extras[i],
161
+ overlayOpen: false,
162
+ };
163
+ const prevSession = host.getActiveCancelSession();
164
+ host.setActiveCancelSession(toolSession);
165
+ const unsubscribeF4 = ctx.mode === "tui"
166
+ ? ctx.ui.onTerminalInput((data) => {
167
+ if (isKeyRelease(data) || !matchesKey(data, Key.f4)) return undefined;
168
+ // A live questionnaire owns F4; pass the key through to it.
169
+ if (host.isAskUserQuestionActive()) return undefined;
170
+ if (toolSession.overlayOpen || !toolSession.run) return undefined;
171
+ toolSession.overlayOpen = true;
172
+ void showCancelOverlay(ctx, toolSession).finally(() => { toolSession.overlayOpen = false; });
173
+ return { consume: true };
174
+ })
175
+ : undefined;
176
+ setCancelHint(ctx, "f4: cancel mf-plan agents");
177
+ try {
178
+ const taskList = tasks.map((t, i) => ({
179
+ agent: t.agent,
180
+ task: t.task,
181
+ ...overrideFor(t.agent),
182
+ ...agentExtensionOptions(t.agent),
183
+ signal: run.add(`${t.agent} #${i + 1}`, signal).signal,
184
+ }));
185
+ const results = await runParallelAgents(
186
+ ctx.cwd,
187
+ agents,
188
+ taskList,
189
+ signal,
190
+ onUpdate,
191
+ (index, result) => {
192
+ run.settle(index, result.cancelled ? "cancelled" : isFailedResult(result) ? "error" : "done");
193
+ },
194
+ (index, result) => {
195
+ if (result.activity) {
196
+ histories[index].push(result.activity);
197
+ while (histories[index].length > 8) histories[index].shift();
198
+ }
199
+ extras[index] = {
200
+ contextTokens: result.usage.contextTokens,
201
+ activity: result.activity,
202
+ loopCount: activityLoopCount(result.activity, histories[index]),
203
+ };
204
+ },
205
+ );
206
+
207
+ // resolveOnAbort makes the runner resolve on abort; rethrow so a
208
+ // whole-turn abort (pi's ESC) keeps its original semantics.
209
+ if (signal?.aborted) throw new Error("Subagent was aborted");
210
+
211
+ const successCount = results.filter((r) => !isFailedResult(r)).length;
212
+ const cancelledCount = results.filter((r) => r.cancelled).length;
213
+ const summaries = results.map((r) => {
214
+ if (r.cancelled) {
215
+ return `### [${r.agent}] cancelled by user\n\nThe user cancelled this subagent before it finished. Do not retry it; proceed with the results from the other agents.`;
216
+ }
217
+ const output = truncateOutput(getResultOutput(r));
218
+ const status = isFailedResult(r) ? "failed" : "completed";
219
+ return `### [${r.agent}] ${status}\n\n${output}`;
220
+ });
221
+
222
+ const header = `Parallel: ${successCount}/${results.length} succeeded${cancelledCount > 0 ? `, ${cancelledCount} cancelled by user` : ""}`;
223
+ return {
224
+ details: undefined,
225
+ content: [
226
+ {
227
+ type: "text",
228
+ text: `${header}\n\n${summaries.join("\n\n---\n\n")}`,
229
+ },
230
+ ],
231
+ };
232
+ } finally {
233
+ unsubscribeF4?.();
234
+ toolSession.closeOverlay?.();
235
+ host.setActiveCancelSession(prevSession);
236
+ setCancelHint(ctx, undefined);
237
+ await warnIfMutated();
238
+ }
239
+ }
240
+
241
+ if (hasSingle) {
242
+ // Single mode — one cancellable slot.
243
+ const run = new CancelRun();
244
+ const slot = run.add(params.agent!, signal);
245
+ const toolSession: CancelSession = { title: "Plan subagent", run, overlayOpen: false };
246
+ const prevSession = host.getActiveCancelSession();
247
+ host.setActiveCancelSession(toolSession);
248
+ const unsubscribeF4 = ctx.mode === "tui"
249
+ ? ctx.ui.onTerminalInput((data) => {
250
+ if (isKeyRelease(data) || !matchesKey(data, Key.f4)) return undefined;
251
+ // A live questionnaire owns F4; pass the key through to it.
252
+ if (host.isAskUserQuestionActive()) return undefined;
253
+ if (toolSession.overlayOpen || !toolSession.run) return undefined;
254
+ toolSession.overlayOpen = true;
255
+ void showCancelOverlay(ctx, toolSession).finally(() => { toolSession.overlayOpen = false; });
256
+ return { consume: true };
257
+ })
258
+ : undefined;
259
+ setCancelHint(ctx, "f4: cancel mf-plan agent");
260
+ try {
261
+ const result = await runSingleAgent(
262
+ ctx.cwd,
263
+ agents,
264
+ params.agent!,
265
+ params.task!,
266
+ undefined,
267
+ slot.signal,
268
+ onUpdate,
269
+ overrideFor(params.agent!)?.model,
270
+ overrideFor(params.agent!)?.thinking,
271
+ { ...agentExtensionOptions(params.agent!), resolveOnAbort: true },
272
+ );
273
+ run.settle(0, result.cancelled ? "cancelled" : isFailedResult(result) ? "error" : "done");
274
+
275
+ if (signal?.aborted) throw new Error("Subagent was aborted");
276
+
277
+ if (result.cancelled) {
278
+ return {
279
+ details: undefined,
280
+ content: [{ type: "text", text: "The user cancelled this subagent. Do not retry; continue planning with the information you already have." }],
281
+ };
282
+ }
283
+ if (isFailedResult(result)) {
284
+ return {
285
+ details: undefined,
286
+ content: [{ type: "text", text: `Agent failed: ${getResultOutput(result)}` }],
287
+ isError: true,
288
+ };
289
+ }
290
+ return {
291
+ details: undefined,
292
+ content: [{ type: "text", text: getFinalOutput(result.messages) || "(no output)" }],
293
+ };
294
+ } finally {
295
+ unsubscribeF4?.();
296
+ toolSession.closeOverlay?.();
297
+ host.setActiveCancelSession(prevSession);
298
+ setCancelHint(ctx, undefined);
299
+ await warnIfMutated();
300
+ }
301
+ }
302
+
303
+ const available = agents.map((a) => `${a.name} (${a.source})`).join(", ") || "none";
304
+ return {
305
+ details: undefined,
306
+ content: [{ type: "text", text: `Invalid parameters. Provide agent+task or tasks array. Available agents: ${available}` }],
307
+ isError: true,
308
+ };
309
+ },
310
+ });
311
+ }
@@ -0,0 +1,19 @@
1
+ export const PLAN_MODE_READ_ONLY_TOOLS = new Set<string>([
2
+ "read",
3
+ "grep",
4
+ "find",
5
+ "ls",
6
+ "fetch_markdown",
7
+ "lsp",
8
+ "memory_read",
9
+ "memory_search",
10
+ "memory_list",
11
+ "web_search",
12
+ "web_fetch",
13
+ ]);
14
+
15
+ export const PLAN_MODE_CUSTOM_TOOLS = ["write_plan", "exit_plan_mode", "mf_plan_subagent", "ask_user_question"];
16
+ export const PLAN_ONLY_REGISTERED_TOOLS = ["write_plan", "exit_plan_mode", "mf_plan_subagent"];
17
+ export const PLAN_SUBAGENT_NAMES = new Set(["moa-explore", "mf-plan"]);
18
+ export const PLAN_MODE_CONTEXT_TYPE = "mf-plan-context";
19
+ export const PLAN_EXIT_CONTEXT_TYPE = "mf-plan-exit";