@underactive/pi-topping-moa-fusion 0.1.0 → 0.2.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.
@@ -27,6 +27,7 @@ export interface OpinionFanoutOptions {
27
27
  session: CancelSession;
28
28
  widget: MoaProgressWidget;
29
29
  agents: AgentConfig[];
30
+ maxConcurrency: number;
30
31
  }
31
32
 
32
33
  export type OpinionFanoutResult =
@@ -34,9 +35,9 @@ export type OpinionFanoutResult =
34
35
  | { status: "cancelled" };
35
36
 
36
37
  export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<OpinionFanoutResult> {
37
- const { host, ctx, question, models, thinking, session, widget, agents } = options;
38
+ const { host, ctx, question, models, thinking, session, widget, agents, maxConcurrency } = options;
38
39
  const originalTask = buildOpinionTask(question);
39
- widget.startFanout(models, thinking);
40
+ widget.startFanout(models, thinking, maxConcurrency);
40
41
  const observe: ObserveSession = {
41
42
  title: "MoA opinions",
42
43
  phase: "fanout",
@@ -57,7 +58,7 @@ export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<O
57
58
  tasks: ModelParallelAgentTask[],
58
59
  indexMap: (index: number) => number,
59
60
  currentRun: CancelRun,
60
- ): Promise<SingleResult[]> => runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models, indexMap });
61
+ ): Promise<SingleResult[]> => runWidgetFanout({ ctx, agents, tasks, run: currentRun, session, widget, observe, models, indexMap, maxConcurrency });
61
62
 
62
63
  const run = new CancelRun();
63
64
  session.title = "Opinion agents";
@@ -107,7 +108,7 @@ export async function runOpinionFanout(options: OpinionFanoutOptions): Promise<O
107
108
  const ref = models[opinionIndex];
108
109
  const label = modelRefLabel(ref);
109
110
  const retryTask = buildOpinionRetryTask(originalTask, getFinalOutput(results[opinionIndex].messages));
110
- widget.update(opinionIndex, "working", "retrying: no opinion produced");
111
+ widget.update(opinionIndex, "queued", "retrying: no opinion produced");
111
112
  const observed = observe.agents[opinionIndex];
112
113
  if (observed) {
113
114
  observed.state = "working";
@@ -1,18 +1,19 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { isKeyRelease, Key, matchesKey } from "@earendil-works/pi-tui";
3
2
 
4
3
  import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../agents/authoritative.ts";
5
4
  import { discoverAgents } from "../agents/discovery.ts";
6
5
  import { summarizePlanPromptName } from "../config/planName.ts";
6
+ import { slugifyPlanName } from "../planning/planFile.ts";
7
7
  import { loadMoaConfig, saveMoaConfig } from "../config/settings.ts";
8
8
  import { resolveContextWindow } from "../moa/modelRuntime.ts";
9
- import type { CancelSession } from "../runtime/cancelRun.ts";
9
+ import { type CancelSession, subscribeCancelOverlayOnEsc } from "../runtime/cancelRun.ts";
10
10
  import { formatMutationWarning, MutationTripwire } from "../runtime/mutationTripwire.ts";
11
11
  import { type SendUserMessageOptions, type ThinkingLevel } from "../shared/modelRefs.ts";
12
12
  import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
13
13
  import type { ObserveSession } from "../ui/observeOverlay.ts";
14
14
  import { showOpinionModelPicker } from "../ui/opinionModelPicker.ts";
15
15
  import { showPromptEditor } from "../ui/promptEditor.ts";
16
+ import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
16
17
  import { runOpinionFanout } from "./opinionFanout.ts";
17
18
  import { repoOpinionDisplayPath, saveRepoOpinionFile } from "./opinionFile.ts";
18
19
  import { collectOpinionOutcomes, formatOpinionsMarkdown } from "./opinionResults.ts";
@@ -45,15 +46,7 @@ export async function runInteractiveOpinion(
45
46
  installShippedAgents();
46
47
  const session: CancelSession = { title: "Opinion agents", run: undefined, overlayOpen: false };
47
48
  host.setActiveCancelSession(session);
48
- const unsubscribeEsc = ctx.mode === "tui"
49
- ? ctx.ui.onTerminalInput((data) => {
50
- if (isKeyRelease(data) || !matchesKey(data, Key.escape)) return undefined;
51
- if (host.getActiveObserveSession()?.overlayOpen) return undefined;
52
- if (session.overlayOpen) return undefined;
53
- if (!session.run) return undefined;
54
- return host.openCancelOverlayIfActive(ctx) ? { consume: true } : undefined;
55
- })
56
- : undefined;
49
+ const unsubscribeEsc = subscribeCancelOverlayOnEsc(ctx, host, session);
57
50
 
58
51
  try {
59
52
  let prefill = initialPrompt?.trim() ?? "";
@@ -83,7 +76,11 @@ export async function runInteractiveOpinion(
83
76
  thinkingOverrides: { ...settings.thinkingOverrides, ...thinkingSelections },
84
77
  });
85
78
 
86
- const slug = await summarizePlanPromptName(ctx, question);
79
+ const slug = await withWorkingOverlay(
80
+ ctx,
81
+ planNamingOverlay("opinion", () => slugifyPlanName(question)),
82
+ (signal) => summarizePlanPromptName(ctx, question, { signal }),
83
+ );
87
84
  try {
88
85
  saveRepoOpinionFile(question, ctx.cwd, slug, "opinion-prompt");
89
86
  } catch {
@@ -114,7 +111,7 @@ export async function runInteractiveOpinion(
114
111
  host.setRunningProgressWidget(widget);
115
112
  let outcome;
116
113
  try {
117
- outcome = await runOpinionFanout({ host, ctx, question, models, thinking, session, widget, agents });
114
+ outcome = await runOpinionFanout({ host, ctx, question, models, thinking, session, widget, agents, maxConcurrency: settings.maxConcurrentAgents });
118
115
  } finally {
119
116
  host.setRunningProgressWidget(undefined);
120
117
  widget.stopWidget();
@@ -139,6 +136,10 @@ export async function runInteractiveOpinion(
139
136
  } catch {
140
137
  saved = false;
141
138
  }
139
+ // `sendUserMessage` routes to prompt() and ignores triggerTurn (only
140
+ // sendCustomMessage honors it), so this report does start a model turn.
141
+ // Genuinely append-only delivery needs pi.sendMessage({ customType, … },
142
+ // { triggerTurn: false }) plus a message renderer — a UX change, not made here.
142
143
  const appendOnly: SendUserMessageOptions = { triggerTurn: false };
143
144
  await pi.sendUserMessage(markdown, appendOnly);
144
145
  const completed = outcomes.filter((item) => item.status === "done").length;
@@ -2,6 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { MfPlanInfo } from "../moa/planInfo.ts";
4
4
  import type { ImplementationHandoff } from "../moa/implementationRetry.ts";
5
+ import { PLAN_ONLY_REGISTERED_TOOLS } from "./tools/shared.ts";
5
6
 
6
7
  export interface PlanModeState {
7
8
  enabled: boolean;
@@ -10,10 +11,26 @@ export interface PlanModeState {
10
11
  needsExitReminder?: boolean;
11
12
  moaInfo?: MfPlanInfo;
12
13
  implementationHandoff?: ImplementationHandoff;
14
+ /** Active loadout captured when plan mode was entered, so exit restores it. */
15
+ toolsBeforePlanMode?: string[];
13
16
  }
14
17
 
15
18
  export const MAX_PERSISTED_STATE_BYTES = 512 * 1024;
16
19
 
20
+ /**
21
+ * A snapshot containing plan-only tools was taken while plan mode was already
22
+ * live (pre-fix sessions, or a transcript-restored loadout). Restoring it would
23
+ * re-apply the read-only set after approval, so reject it and let the caller
24
+ * fall back to the registered loadout.
25
+ */
26
+ export function validateToolLoadoutSnapshot(value: unknown): string[] | undefined {
27
+ if (!Array.isArray(value)) return undefined;
28
+ const names = value.filter((name): name is string => typeof name === "string" && name.length > 0);
29
+ if (names.length === 0) return undefined;
30
+ if (names.some((name) => PLAN_ONLY_REGISTERED_TOOLS.includes(name))) return undefined;
31
+ return [...new Set(names)];
32
+ }
33
+
17
34
  export function serializePlanModeState(state: PlanModeState): { state: PlanModeState; serialized: string } {
18
35
  let persisted = state.moaInfo?.proposerPlans
19
36
  ? { ...state, moaInfo: { ...state.moaInfo, proposerPlans: undefined } }
@@ -136,7 +136,7 @@ export function fallbackPlanName(): string {
136
136
  return generateWordSlug();
137
137
  }
138
138
 
139
- export type RepoPlanFileKind = "plan-prompt" | "plan" | "verification" | "criteria";
139
+ export type RepoPlanFileKind = "plan-prompt" | "plan" | "verification" | "criteria" | "verification-handoff";
140
140
 
141
141
  function repoPlanSuffix(kind: RepoPlanFileKind): string {
142
142
  // `__verification` and `__criteria` never collide with the `/mf-plan-implement`
@@ -144,6 +144,7 @@ function repoPlanSuffix(kind: RepoPlanFileKind): string {
144
144
  if (kind === "plan-prompt") return "__plan-prompt";
145
145
  if (kind === "verification") return "__verification";
146
146
  if (kind === "criteria") return "__criteria";
147
+ if (kind === "verification-handoff") return "__verification-handoff";
147
148
  return "__plan";
148
149
  }
149
150
 
@@ -37,10 +37,11 @@ import { showCancelOverlay } from "../ui/cancelOverlay.ts";
37
37
  import { UI_TICK_MS } from "../ui/chrome.ts";
38
38
  import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
39
39
  import { showObserveOverlay, type ObserveSession } from "../ui/observeOverlay.ts";
40
+ import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
40
41
  import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTracker, isAskUserQuestionInstalled } from "./askUserQuestion.ts";
41
42
  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";
43
+ import { latestPlanModeStateEntry, PlanModeStatePersistence, validateToolLoadoutSnapshot } from "./modeState.ts";
44
+ import { getPlan, getPlanSlug, isValidPlanSlug, resetPlanSlug, saveRepoPlanFile, setPlanSlug, slugifyPlanName } from "./planFile.ts";
44
45
  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
 
46
47
  /** Trailing chars of the implementer report kept for the verifier's evidence. */
@@ -168,6 +169,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
168
169
  needsExitReminder,
169
170
  moaInfo: activeRunMoaInfo,
170
171
  implementationHandoff,
172
+ toolsBeforePlanMode,
171
173
  });
172
174
  const footerState = (ctx: ExtensionContext): MoaFooterState => {
173
175
  if (footerError) return "error";
@@ -218,6 +220,21 @@ export function createPlanModeController(pi: ExtensionAPI) {
218
220
  const customTools = PLAN_MODE_CUSTOM_TOOLS.filter((name) => name !== ASK_USER_QUESTION_TOOL_NAME || isAskUserQuestionInstalled(pi));
219
221
  return [...new Set([...filtered, ...customTools])];
220
222
  };
223
+ /**
224
+ * Every registered tool except the plan-only ones — the safe fallback when no
225
+ * trustworthy snapshot exists. Note this can restore a tool the user had
226
+ * deliberately deactivated; that is strictly better than leaving the
227
+ * implementer read-only, and only affects sessions saved before this field
228
+ * existed. Deduped because registration re-adds already-present tools.
229
+ */
230
+ const fullLoadoutFallback = (): string[] => {
231
+ const registered = [...new Set(
232
+ pi.getAllTools().map((tool) => tool.name).filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name)),
233
+ )];
234
+ // An empty registered list would strip every tool; prefer the active set.
235
+ if (registered.length > 0) return registered;
236
+ return [...new Set(pi.getActiveTools().filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name)))];
237
+ };
221
238
  const applyReadOnlyProviderEnv = (): void => {
222
239
  if (providerEnvBeforePlanMode === undefined) {
223
240
  providerEnvBeforePlanMode = {};
@@ -234,7 +251,15 @@ export function createPlanModeController(pi: ExtensionAPI) {
234
251
  providerEnvBeforePlanMode = undefined;
235
252
  };
236
253
  const enablePlanModeTools = (): void => {
237
- if (toolsBeforePlanMode === undefined) toolsBeforePlanMode = pi.getActiveTools();
254
+ if (toolsBeforePlanMode === undefined) {
255
+ // 0.86.0 restores tools from the transcript before session_start, and a
256
+ // leaked plan-only set is possible mid-session, so an "active" set that
257
+ // contains plan-only tools is the plan loadout, not the user's.
258
+ const active = pi.getActiveTools();
259
+ toolsBeforePlanMode = active.some((name) => PLAN_ONLY_REGISTERED_TOOLS.includes(name))
260
+ ? fullLoadoutFallback()
261
+ : active;
262
+ }
238
263
  applyReadOnlyProviderEnv();
239
264
  pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
240
265
  };
@@ -356,7 +381,11 @@ export function createPlanModeController(pi: ExtensionAPI) {
356
381
  return lastReentryState;
357
382
  };
358
383
  async function saveSubmittedPlanPrompt(ctx: ExtensionContext, prompt: string): Promise<void> {
359
- planRepoSlug = await summarizePlanPromptName(ctx, prompt);
384
+ planRepoSlug = await withWorkingOverlay(
385
+ ctx,
386
+ planNamingOverlay("plan", () => slugifyPlanName(prompt)),
387
+ (signal) => summarizePlanPromptName(ctx, prompt, { signal }),
388
+ );
360
389
  persistState();
361
390
  saveRepoPlanFile(prompt, ctx.cwd, planRepoSlug, "plan-prompt");
362
391
  }
@@ -397,7 +426,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
397
426
  Implement: implementationHandoff.model,
398
427
  Verify: implementationHandoff.verifier,
399
428
  });
400
- widget.switchToImplementing(implementationHandoff.model, "implementing plan…", implementationHandoff.thinking);
429
+ widget.switchToImplementing(implementationHandoff.model, "implementing plan", implementationHandoff.thinking);
401
430
  activeProgressWidget = widget;
402
431
  }
403
432
  if (ctx) updateStatus(ctx);
@@ -625,6 +654,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
625
654
  resetPlanSlug();
626
655
  if (pi.getFlag("mf-plan") === true) planModeEnabled = true;
627
656
  const restored = latestPlanModeStateEntry(ctx.sessionManager.getEntries());
657
+ const restoredPlanModeWasEnabled = restored?.enabled === true;
628
658
  if (restored) {
629
659
  planModeEnabled = restored.enabled ?? planModeEnabled;
630
660
  needsExitReminder = restored.needsExitReminder ?? false;
@@ -632,10 +662,21 @@ export function createPlanModeController(pi: ExtensionAPI) {
632
662
  planRepoSlug = isValidPlanSlug(restored.repoPlanSlug) ? restored.repoPlanSlug : undefined;
633
663
  activeRunMoaInfo = validateMfPlanInfo(restored.moaInfo);
634
664
  implementationHandoff = validateImplementationHandoff(restored.implementationHandoff);
665
+ toolsBeforePlanMode = validateToolLoadoutSnapshot(restored.toolsBeforePlanMode);
635
666
  }
636
667
  if (planModeEnabled) {
637
668
  lastReentryState = getPlan() !== null;
669
+ // A persisted plan-mode session had its loadout restored from the transcript
670
+ // before this handler ran (#9548), so the active set is the plan loadout, not
671
+ // the user's. Only the persisted snapshot or the registered loadout can be
672
+ // trusted here. The flag-only path (no entry) is excluded: there plan mode
673
+ // never ran, so the active set really is the user's.
674
+ const repairedLoadoutSnapshot = restoredPlanModeWasEnabled && toolsBeforePlanMode === undefined;
675
+ if (repairedLoadoutSnapshot) toolsBeforePlanMode = fullLoadoutFallback();
638
676
  enablePlanModeTools();
677
+ // Re-persist the restored (and, if needed, repaired) snapshot so later
678
+ // resumes of this session see the same trustworthy loadout.
679
+ if (restoredPlanModeWasEnabled) persistState();
639
680
  } else {
640
681
  deactivatePlanOnlyTools();
641
682
  }
@@ -7,8 +7,9 @@ import { stripSynthSections } from "../../moa/verdicts.ts";
7
7
  import { FOLLOW_UP } from "../../shared/modelRefs.ts";
8
8
  import type { MfPlanInfo, PlanReviewDecision } from "../../moa/planInfo.ts";
9
9
  import { showPlanReview } from "../../ui/planReviewOverlay.ts";
10
+ import { planNamingOverlay, withWorkingOverlay } from "../../ui/workingOverlay.ts";
10
11
  import { ASK_USER_QUESTION_PENDING_MESSAGE } from "../askUserQuestion.ts";
11
- import { getPlan, getPlanFilePath, saveRepoPlanFile, writePlan } from "../planFile.ts";
12
+ import { getPlan, getPlanFilePath, saveRepoPlanFile, slugifyPlanName, writePlan } from "../planFile.ts";
12
13
 
13
14
  import type { ImplementationHandoff } from "../../moa/implementationRetry.ts";
14
15
 
@@ -98,7 +99,11 @@ export function registerExitPlanModeTool(pi: ExtensionAPI, host: ExitPlanModeHos
98
99
  }
99
100
 
100
101
  if (!host.getPlanRepoSlug()) {
101
- host.setPlanRepoSlug(await summarizePlanPromptName(ctx, plan));
102
+ host.setPlanRepoSlug(await withWorkingOverlay(
103
+ ctx,
104
+ planNamingOverlay("plan", () => slugifyPlanName(plan)),
105
+ (signal) => summarizePlanPromptName(ctx, plan, { signal }),
106
+ ));
102
107
  host.persistState();
103
108
  }
104
109
 
@@ -6,6 +6,7 @@ import { Type } from "typebox";
6
6
  import * as path from "node:path";
7
7
 
8
8
  import { installShippedAgents, shippedAgentsDir, withAuthoritativeMoaAgents } from "../../agents/authoritative.ts";
9
+ import { loadMoaConfig } from "../../config/settings.ts";
9
10
  import { discoverAgents, parseAgentFile } from "../../agents/discovery.ts";
10
11
  import { modelExtensionOptions } from "../../moa/modelRuntime.ts";
11
12
  import { CancelRun, type CancelRowExtras, type CancelSession } from "../../runtime/cancelRun.ts";
@@ -150,6 +151,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
150
151
  // Parallel mode — each task gets its own abort signal (combined
151
152
  // with pi's turn signal) so F4 can kill one stuck agent
152
153
  // while its siblings keep working.
154
+ const maxConcurrency = loadMoaConfig().maxConcurrentAgents;
153
155
  const tasks = params.tasks!;
154
156
  const run = new CancelRun();
155
157
  const extras: CancelRowExtras[] = tasks.map(() => ({}));
@@ -202,6 +204,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
202
204
  loopCount: activityLoopCount(result.activity, histories[index]),
203
205
  };
204
206
  },
207
+ { maxConcurrency },
205
208
  );
206
209
 
207
210
  // resolveOnAbort makes the runner resolve on abort; rethrow so a
@@ -1,8 +1,8 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { AssistantMessage, Message } from "@earendil-works/pi-ai";
2
+ import type { AssistantMessage, Message, ToolCall } from "@earendil-works/pi-ai";
3
3
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
4
4
  import { StreamingWordCounter } from "../activityMeter.ts";
5
- import { isNonnegativeFiniteNumber, isValidContentIndex, type WireAssistantMessageEvent } from "./wire.ts";
5
+ import { isNonnegativeFiniteNumber, isValidContentIndex } from "./wire.ts";
6
6
 
7
7
  /** Max length of a one-line tool-activity description surfaced for progress UIs. */
8
8
  const ACTIVITY_MAX = 80;
@@ -94,6 +94,19 @@ export class OutputActivityTracker {
94
94
 
95
95
  type AssistantContentPart = AssistantMessage["content"][number];
96
96
 
97
+ /**
98
+ * The only fields `apply` reads. Declared structurally rather than as the wire
99
+ * union so both the JSON-protocol delta and the in-process
100
+ * `MessageUpdateEvent["assistantMessageEvent"]` are assignable — those two
101
+ * diverged when JSON `toolcall_start` gained required `id`/`toolName` (#7953).
102
+ */
103
+ export interface PartialAssistantDelta {
104
+ type: string;
105
+ contentIndex?: number;
106
+ delta?: string;
107
+ toolCall?: ToolCall;
108
+ }
109
+
97
110
  /**
98
111
  * Rebuilds the in-flight assistant message from streamed deltas.
99
112
  *
@@ -111,19 +124,22 @@ export class PartialAssistantAssembler {
111
124
  this.#parts = [];
112
125
  }
113
126
 
114
- apply(assistantEvent: WireAssistantMessageEvent | undefined): void {
127
+ apply(assistantEvent: PartialAssistantDelta | undefined): void {
115
128
  if (!assistantEvent) return;
116
- if (("contentIndex" in assistantEvent && !isValidContentIndex(assistantEvent.contentIndex))) return;
117
- if (assistantEvent.type === "text_delta") {
118
- const part = this.#parts[assistantEvent.contentIndex];
129
+ const contentIndex = assistantEvent.contentIndex;
130
+ // Every handled delta kind is content-indexed, so an absent or out-of-range
131
+ // index is a no-op — same net effect as the previous `in` check.
132
+ if (!isValidContentIndex(contentIndex)) return;
133
+ if (assistantEvent.type === "text_delta" && typeof assistantEvent.delta === "string") {
134
+ const part = this.#parts[contentIndex];
119
135
  if (part?.type === "text") part.text += assistantEvent.delta;
120
- else this.#parts[assistantEvent.contentIndex] = { type: "text", text: assistantEvent.delta };
121
- } else if (assistantEvent.type === "thinking_delta") {
122
- const part = this.#parts[assistantEvent.contentIndex];
136
+ else this.#parts[contentIndex] = { type: "text", text: assistantEvent.delta };
137
+ } else if (assistantEvent.type === "thinking_delta" && typeof assistantEvent.delta === "string") {
138
+ const part = this.#parts[contentIndex];
123
139
  if (part?.type === "thinking") part.thinking += assistantEvent.delta;
124
- else this.#parts[assistantEvent.contentIndex] = { type: "thinking", thinking: assistantEvent.delta };
125
- } else if (assistantEvent.type === "toolcall_end") {
126
- this.#parts[assistantEvent.contentIndex] = assistantEvent.toolCall;
140
+ else this.#parts[contentIndex] = { type: "thinking", thinking: assistantEvent.delta };
141
+ } else if (assistantEvent.type === "toolcall_end" && assistantEvent.toolCall) {
142
+ this.#parts[contentIndex] = assistantEvent.toolCall;
127
143
  }
128
144
  }
129
145
 
@@ -38,6 +38,7 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
38
38
  items: TIn[],
39
39
  concurrency: number,
40
40
  fn: (item: TIn, index: number) => Promise<TOut>,
41
+ onStart?: (index: number) => void,
41
42
  ): Promise<TOut[]> {
42
43
  if (items.length === 0) return [];
43
44
  const limit = Math.max(1, Math.min(concurrency, items.length));
@@ -47,6 +48,7 @@ export async function mapWithConcurrencyLimit<TIn, TOut>(
47
48
  while (true) {
48
49
  const current = nextIndex++;
49
50
  if (current >= items.length) return;
51
+ onStart?.(current);
50
52
  results[current] = await fn(items[current], current);
51
53
  }
52
54
  });
@@ -10,6 +10,7 @@ import * as path from "node:path";
10
10
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
11
11
  import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
12
12
  import type { AgentConfig } from "../agents/discovery.ts";
13
+ import { normalizeMaxConcurrentAgents } from "../config/settings.ts";
13
14
  import type { ThinkingLevel } from "../shared/modelRefs.ts";
14
15
  import { OutputActivityTracker, PartialAssistantAssembler, formatToolActivity } from "./activityTracking.ts";
15
16
  import { escalateKill, mapWithConcurrencyLimit, trackedProcesses } from "./processPool.ts";
@@ -24,7 +25,6 @@ import {
24
25
  } from "./wire.ts";
25
26
 
26
27
  const MAX_PARALLEL_TASKS = 8;
27
- const MAX_CONCURRENCY = 5;
28
28
  const CHILD_IDLE_TIMEOUT_MS = 15 * 60 * 1000;
29
29
  const MAX_DIAGNOSTIC_CHARS = 65_536;
30
30
  const READ_ONLY_AGENT_TOOLS = ["read", "grep", "find", "ls"] as const;
@@ -94,8 +94,9 @@ export interface RunSingleAgentOptions {
94
94
  * Called on every streamed turn boundary (each `message_end`) with the live,
95
95
  * in-progress result. Lets callers observe running usage — notably
96
96
  * `usage.contextTokens` (the latest turn's total context size) — so a progress
97
- * widget can show a growing context-usage bar while the agent works. Providers
98
- * that run a whole task as one turn only report at the end; see
97
+ * widget can show a growing context-usage bar while the agent works.
98
+ * `contextTokens` advances from `message_update` usage, authoritative
99
+ * `message_end` usage, and the stderr beacon; see
99
100
  * docs/handoff-bridge-live-context.md.
100
101
  */
101
102
  onProgress?: (result: SingleResult) => void;
@@ -249,6 +250,23 @@ export async function runSingleAgent(
249
250
  outputActivity.messageUpdate(event.assistantMessageEvent);
250
251
  partialMessage.apply(event.assistantMessageEvent);
251
252
  currentResult.partialAssistant = partialMessage.snapshot();
253
+ // Interim context reading. Providers that run a whole task as one turn emit a
254
+ // single message_end, so without this the CTX bar sits at 0% for the run.
255
+ // A malformed or absent reading is ignored rather than rejected upstream, so a
256
+ // bad number never costs the run its text deltas.
257
+ // "beacon" source: never lowers a value and never overrides an authoritative
258
+ // message_end reading for the same turn.
259
+ const liveTotal = event.usage?.totalTokens;
260
+ if (isValidContextTokens(liveTotal)) {
261
+ const reconciled = reconcileContextTokens(
262
+ currentResult.usage.contextTokens,
263
+ contextUsageIsAuthoritative,
264
+ liveTotal,
265
+ "beacon",
266
+ );
267
+ currentResult.usage.contextTokens = reconciled.contextTokens;
268
+ contextUsageIsAuthoritative = reconciled.authoritative;
269
+ }
252
270
  emitUpdate();
253
271
  }
254
272
 
@@ -413,6 +431,13 @@ export interface ModelParallelAgentTask extends ParallelAgentTask {
413
431
  model: string;
414
432
  }
415
433
 
434
+ export interface ParallelRunOptions {
435
+ /** Max child processes in flight. Normalized here, so an omitted or malformed value falls back to the safe default. */
436
+ maxConcurrency?: number;
437
+ /** Called once a queued task acquires a process-pool slot, immediately before it begins. */
438
+ onStart?: (index: number) => void;
439
+ }
440
+
416
441
  /** Run multiple agents in parallel with concurrency limit. */
417
442
  export async function runParallelAgents(
418
443
  defaultCwd: string,
@@ -422,11 +447,14 @@ export async function runParallelAgents(
422
447
  onUpdate: ((partial: AgentToolResult<unknown>) => void) | undefined,
423
448
  onEach?: (index: number, result: SingleResult) => void,
424
449
  onProgress?: (index: number, result: SingleResult) => void,
450
+ options: ParallelRunOptions = {},
425
451
  ): Promise<SingleResult[]> {
426
452
  if (tasks.length > MAX_PARALLEL_TASKS) {
427
453
  throw new Error(`Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`);
428
454
  }
429
455
 
456
+ const maxConcurrency = normalizeMaxConcurrentAgents(options.maxConcurrency);
457
+
430
458
  const allResults: SingleResult[] = new Array(tasks.length);
431
459
 
432
460
  for (let i = 0; i < tasks.length; i++) {
@@ -441,10 +469,12 @@ export async function runParallelAgents(
441
469
  };
442
470
  }
443
471
 
472
+ const startedIndices = new Set<number>();
473
+
444
474
  const emitParallelUpdate = () => {
445
475
  if (onUpdate) {
446
- const running = allResults.filter((r) => r.exitCode === -1).length;
447
476
  const done = allResults.filter((r) => r.exitCode !== -1).length;
477
+ const running = startedIndices.size - done;
448
478
  onUpdate({
449
479
  details: undefined,
450
480
  content: [{ type: "text", text: `Parallel: ${done}/${allResults.length} done, ${running} running...` }],
@@ -452,10 +482,10 @@ export async function runParallelAgents(
452
482
  }
453
483
  };
454
484
 
455
- const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
485
+ const results = await mapWithConcurrencyLimit(tasks, maxConcurrency, async (t, index) => {
456
486
  const taskSignal = t.signal ?? signal;
457
487
  if (taskSignal?.aborted) {
458
- // Cancelled while queued behind MAX_CONCURRENCY — never spawn.
488
+ // Cancelled while queued behind the configured concurrency limit — never spawn.
459
489
  const placeholder = cancelledPlaceholderResult(t.agent, t.task);
460
490
  allResults[index] = placeholder;
461
491
  emitParallelUpdate();
@@ -483,6 +513,9 @@ export async function runParallelAgents(
483
513
  emitParallelUpdate();
484
514
  onEach?.(index, result);
485
515
  return result;
516
+ }, (index) => {
517
+ startedIndices.add(index);
518
+ options.onStart?.(index);
486
519
  });
487
520
 
488
521
  return results;
@@ -500,15 +533,18 @@ export async function runParallelAgentsWithModels(
500
533
  signal: AbortSignal | undefined,
501
534
  onEach: ((index: number, result: SingleResult) => void) | undefined,
502
535
  onProgress?: (index: number, result: SingleResult) => void,
536
+ options: ParallelRunOptions = {},
503
537
  ): Promise<SingleResult[]> {
504
538
  if (tasks.length > MAX_PARALLEL_TASKS) {
505
539
  throw new Error(`Too many parallel tasks (${tasks.length}). Max is ${MAX_PARALLEL_TASKS}.`);
506
540
  }
507
541
 
508
- const results = await mapWithConcurrencyLimit(tasks, MAX_CONCURRENCY, async (t, index) => {
542
+ const maxConcurrency = normalizeMaxConcurrentAgents(options.maxConcurrency);
543
+
544
+ const results = await mapWithConcurrencyLimit(tasks, maxConcurrency, async (t, index) => {
509
545
  const taskSignal = t.signal ?? signal;
510
546
  if (taskSignal?.aborted) {
511
- // Cancelled while queued behind MAX_CONCURRENCY — never spawn.
547
+ // Cancelled while queued behind the configured concurrency limit — never spawn.
512
548
  const placeholder = cancelledPlaceholderResult(t.agent, t.task);
513
549
  onEach?.(index, placeholder);
514
550
  return placeholder;
@@ -532,7 +568,7 @@ export async function runParallelAgentsWithModels(
532
568
  );
533
569
  onEach?.(index, result);
534
570
  return result;
535
- });
571
+ }, options.onStart);
536
572
 
537
573
  return results;
538
574
  }
@@ -71,6 +71,17 @@ function isWireMessage(value: unknown): boolean {
71
71
  return typeof value.content === "string"
72
72
  || (Array.isArray(value.content) && value.content.every((part) => isTextContent(part) || isImageContent(part)));
73
73
  }
74
+ // 0.86.0 children emit a system message_start/message_end pair carrying the
75
+ // prompt and tool-loadout declaration. It never reaches `messages` (isLlmMessage
76
+ // filters it) and both trackers ignore non-assistant roles, but rejecting it
77
+ // here put two spurious parse errors at the head of every failure report.
78
+ // The pair arrives before any assistant stream, so `partialMessage.start()`
79
+ // clearing the in-flight partial on a non-assistant message_start loses
80
+ // nothing today — worth re-checking if a future protocol interleaves them.
81
+ if (value.role === "system") {
82
+ return typeof value.content === "string"
83
+ || (Array.isArray(value.content) && value.content.every((part) => isTextContent(part)));
84
+ }
74
85
  if (value.role === "assistant") {
75
86
  return Array.isArray(value.content)
76
87
  && value.content.every((part) => isTextContent(part) || isThinkingContent(part) || isToolCall(part));
@@ -150,7 +150,7 @@ class CancelOverlayComponent implements Component {
150
150
  : spin;
151
151
  const stateText =
152
152
  agent.state === "running" ? "running"
153
- : agent.state === "cancelling" ? "cancelling…"
153
+ : agent.state === "cancelling" ? "cancelling"
154
154
  : agent.state;
155
155
  const extras = this.session.getExtras?.(i);
156
156
  const bar = extras && (extras.contextTokens !== undefined || extras.contextWindow !== undefined)
@@ -44,6 +44,7 @@ import {
44
44
  import { PLAN_OVERLAY_OPTIONS } from "./menu.ts";
45
45
  import { TwoPaneModelThinking } from "./twoPaneModelThinking.ts";
46
46
  import { smartTruncateModelLabel } from "./modelLabel.ts";
47
+ import { proposerDiversityWarning } from "../moa/panelDependence.ts";
47
48
  import { getModelCatalogue } from "../config/modelCatalogue.ts";
48
49
  import {
49
50
  loadMoaConfig,
@@ -244,6 +245,11 @@ class MoaModelPickerComponent implements Component {
244
245
  return this.proposerRefs.reduce((n, ref) => (ref ? n + 1 : n), 0);
245
246
  }
246
247
 
248
+ private diversityWarning(): string | undefined {
249
+ const assigned = this.proposerRefs.filter((ref): ref is ModelRef => ref !== undefined);
250
+ return proposerDiversityWarning(assigned);
251
+ }
252
+
247
253
  /**
248
254
  * Ready to start once at least MIN_PROPOSERS proposer slots plus all three
249
255
  * required roles hold explicit assignments. Counts assigned slots, not
@@ -587,7 +593,11 @@ class MoaModelPickerComponent implements Component {
587
593
  slotRow("Verifier", detail("Verifier", this.verifierRef, this.verifierThinking), VERIFIER_ROW),
588
594
  startRow,
589
595
  ];
590
- const reviewRows = [loadRow, row(""), ...slotRows.slice(1, -1), row(""), startRow];
596
+ const warning = this.diversityWarning();
597
+ const warningRows = warning
598
+ ? wrapWords(warning, bodyWidth - 1).map((line) => row(th.fg("dim", line)))
599
+ : [];
600
+ const reviewRows = [loadRow, row(""), ...slotRows.slice(1, -1), row(""), ...warningRows, startRow];
591
601
  const lines = [
592
602
  topBorder,
593
603
  row(th.fg("accent", "MoA Fusion Pre-flight")),