@underactive/pi-topping-moa-fusion 0.1.0 → 0.3.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 (43) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +54 -12
  3. package/agents/moa-synthesizer.md +9 -6
  4. package/package.json +5 -5
  5. package/src/config/planName.ts +29 -24
  6. package/src/config/settings.ts +38 -1
  7. package/src/debate/debateFanout.ts +16 -6
  8. package/src/debate/runDebate.ts +13 -4
  9. package/src/index.ts +14 -3
  10. package/src/moa/conflictContract.ts +2 -1
  11. package/src/moa/contextContract.ts +1 -1
  12. package/src/moa/fanout.ts +115 -4
  13. package/src/moa/fanoutWiring.ts +13 -3
  14. package/src/moa/implementationRetry.ts +2 -2
  15. package/src/moa/orchestration.ts +3 -0
  16. package/src/moa/panelDependence.ts +87 -0
  17. package/src/moa/reviewLoop.ts +2 -2
  18. package/src/moa/synthesis.ts +12 -8
  19. package/src/moa/verification.ts +117 -20
  20. package/src/moa/verificationCriteria.ts +1 -1
  21. package/src/opinion/opinionFanout.ts +5 -4
  22. package/src/opinion/runOpinion.ts +14 -13
  23. package/src/planning/contextEdits.ts +62 -0
  24. package/src/planning/modeState.ts +17 -0
  25. package/src/planning/planFile.ts +30 -12
  26. package/src/planning/planMode.ts +144 -11
  27. package/src/planning/tools/exitPlanMode.ts +7 -2
  28. package/src/planning/tools/mfPlanSubagent.ts +3 -0
  29. package/src/planning/tools/shared.ts +1 -0
  30. package/src/runtime/activityTracking.ts +28 -12
  31. package/src/runtime/processPool.ts +2 -0
  32. package/src/runtime/results.ts +18 -0
  33. package/src/runtime/runner.ts +45 -9
  34. package/src/runtime/wire.ts +11 -0
  35. package/src/spec/runSpec.ts +343 -0
  36. package/src/spec/specContract.ts +324 -0
  37. package/src/ui/cancelOverlay.ts +1 -1
  38. package/src/ui/moaModelPicker.ts +32 -4
  39. package/src/ui/moaProgressWidget.ts +114 -12
  40. package/src/ui/moaSetupOverlay.ts +51 -7
  41. package/src/ui/planReviewOverlay.ts +16 -8
  42. package/src/ui/rosterEditor.ts +18 -12
  43. package/src/ui/workingOverlay.ts +265 -0
@@ -1,7 +1,8 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import type { Message, TextContent } from "@earendil-works/pi-ai";
2
+ import type { Message } from "@earendil-works/pi-ai";
3
3
  import { Key } from "@earendil-works/pi-tui";
4
4
  import type {
5
+ AgentBeforeSettleEvent,
5
6
  AgentSettledEvent,
6
7
  AgentStartEvent,
7
8
  ExtensionAPI,
@@ -9,6 +10,7 @@ import type {
9
10
  MessageEndEvent,
10
11
  MessageStartEvent,
11
12
  MessageUpdateEvent,
13
+ SessionBoundaryDraft,
12
14
  ThemeColor,
13
15
  ToolExecutionStartEvent,
14
16
  } from "@earendil-works/pi-coding-agent";
@@ -37,11 +39,20 @@ import { showCancelOverlay } from "../ui/cancelOverlay.ts";
37
39
  import { UI_TICK_MS } from "../ui/chrome.ts";
38
40
  import { MoaProgressWidget } from "../ui/moaProgressWidget.ts";
39
41
  import { showObserveOverlay, type ObserveSession } from "../ui/observeOverlay.ts";
42
+ import { planNamingOverlay, withWorkingOverlay } from "../ui/workingOverlay.ts";
40
43
  import { ASK_USER_QUESTION_TOOL_NAME, createAskUserQuestionTracker, isAskUserQuestionInstalled } from "./askUserQuestion.ts";
44
+ import {
45
+ buildVerificationPendingMessage,
46
+ collectOmissionDrafts,
47
+ isPlanInstructionEntry,
48
+ isPlanModeInstructionText,
49
+ isVerificationPendingEntry,
50
+ type ScannedEntry,
51
+ } from "./contextEdits.ts";
41
52
  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";
53
+ import { latestPlanModeStateEntry, PlanModeStatePersistence, validateToolLoadoutSnapshot } from "./modeState.ts";
54
+ import { getPlan, getPlanSlug, isValidPlanSlug, resetPlanSlug, saveRepoPlanFile, setPlanSlug, slugifyPlanName } from "./planFile.ts";
55
+ import { PLAN_EXIT_CONTEXT_TYPE, PLAN_MODE_CONTEXT_TYPE, PLAN_MODE_CUSTOM_TOOLS, PLAN_MODE_READ_ONLY_TOOLS, PLAN_ONLY_REGISTERED_TOOLS, VERIFICATION_PENDING_CONTEXT_TYPE } from "./tools/shared.ts";
45
56
 
46
57
  /** Trailing chars of the implementer report kept for the verifier's evidence. */
47
58
  const IMPLEMENTATION_REPORT_MAX_CHARS = 8000;
@@ -85,6 +96,17 @@ function extractAssistantText(content: unknown): string {
85
96
  return "";
86
97
  }
87
98
 
99
+ // Existing tests and stale post-reload contexts may not expose getBranch;
100
+ // boundary handlers should become inert rather than throw in that case.
101
+ function readBranchEntries(ctx: ExtensionContext): readonly ScannedEntry[] {
102
+ try {
103
+ const manager = ctx.sessionManager as { getBranch?: () => readonly ScannedEntry[] };
104
+ return typeof manager.getBranch === "function" ? manager.getBranch() : [];
105
+ } catch {
106
+ return [];
107
+ }
108
+ }
109
+
88
110
  export function createPlanModeController(pi: ExtensionAPI) {
89
111
  let planModeEnabled = false;
90
112
  let toolsBeforePlanMode: string[] | undefined;
@@ -98,6 +120,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
98
120
  let activeRunMoaInfo: MfPlanInfo | undefined;
99
121
  let implementationHandoff: ImplementationHandoff | undefined;
100
122
  let implementationPending = false;
123
+ let verificationInFlight = false;
124
+ const omittedEntryIds = new Set<string>();
101
125
  let lastImplementationStopReason: string | undefined;
102
126
  // The implementer's final assistant text, captured while pending so the
103
127
  // verifier can be handed the implementer's own (untrusted) report. Capped to
@@ -168,6 +192,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
168
192
  needsExitReminder,
169
193
  moaInfo: activeRunMoaInfo,
170
194
  implementationHandoff,
195
+ toolsBeforePlanMode,
171
196
  });
172
197
  const footerState = (ctx: ExtensionContext): MoaFooterState => {
173
198
  if (footerError) return "error";
@@ -218,6 +243,21 @@ export function createPlanModeController(pi: ExtensionAPI) {
218
243
  const customTools = PLAN_MODE_CUSTOM_TOOLS.filter((name) => name !== ASK_USER_QUESTION_TOOL_NAME || isAskUserQuestionInstalled(pi));
219
244
  return [...new Set([...filtered, ...customTools])];
220
245
  };
246
+ /**
247
+ * Every registered tool except the plan-only ones — the safe fallback when no
248
+ * trustworthy snapshot exists. Note this can restore a tool the user had
249
+ * deliberately deactivated; that is strictly better than leaving the
250
+ * implementer read-only, and only affects sessions saved before this field
251
+ * existed. Deduped because registration re-adds already-present tools.
252
+ */
253
+ const fullLoadoutFallback = (): string[] => {
254
+ const registered = [...new Set(
255
+ pi.getAllTools().map((tool) => tool.name).filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name)),
256
+ )];
257
+ // An empty registered list would strip every tool; prefer the active set.
258
+ if (registered.length > 0) return registered;
259
+ return [...new Set(pi.getActiveTools().filter((name) => !PLAN_ONLY_REGISTERED_TOOLS.includes(name)))];
260
+ };
221
261
  const applyReadOnlyProviderEnv = (): void => {
222
262
  if (providerEnvBeforePlanMode === undefined) {
223
263
  providerEnvBeforePlanMode = {};
@@ -234,7 +274,15 @@ export function createPlanModeController(pi: ExtensionAPI) {
234
274
  providerEnvBeforePlanMode = undefined;
235
275
  };
236
276
  const enablePlanModeTools = (): void => {
237
- if (toolsBeforePlanMode === undefined) toolsBeforePlanMode = pi.getActiveTools();
277
+ if (toolsBeforePlanMode === undefined) {
278
+ // 0.86.0 restores tools from the transcript before session_start, and a
279
+ // leaked plan-only set is possible mid-session, so an "active" set that
280
+ // contains plan-only tools is the plan loadout, not the user's.
281
+ const active = pi.getActiveTools();
282
+ toolsBeforePlanMode = active.some((name) => PLAN_ONLY_REGISTERED_TOOLS.includes(name))
283
+ ? fullLoadoutFallback()
284
+ : active;
285
+ }
238
286
  applyReadOnlyProviderEnv();
239
287
  pi.setActiveTools(getPlanModeTools(toolsBeforePlanMode));
240
288
  };
@@ -356,7 +404,11 @@ export function createPlanModeController(pi: ExtensionAPI) {
356
404
  return lastReentryState;
357
405
  };
358
406
  async function saveSubmittedPlanPrompt(ctx: ExtensionContext, prompt: string): Promise<void> {
359
- planRepoSlug = await summarizePlanPromptName(ctx, prompt);
407
+ planRepoSlug = await withWorkingOverlay(
408
+ ctx,
409
+ planNamingOverlay("plan", () => slugifyPlanName(prompt)),
410
+ (signal) => summarizePlanPromptName(ctx, prompt, { signal }),
411
+ );
360
412
  persistState();
361
413
  saveRepoPlanFile(prompt, ctx.cwd, planRepoSlug, "plan-prompt");
362
414
  }
@@ -397,7 +449,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
397
449
  Implement: implementationHandoff.model,
398
450
  Verify: implementationHandoff.verifier,
399
451
  });
400
- widget.switchToImplementing(implementationHandoff.model, "implementing plan…", implementationHandoff.thinking);
452
+ widget.switchToImplementing(implementationHandoff.model, "implementing plan", implementationHandoff.thinking);
401
453
  activeProgressWidget = widget;
402
454
  }
403
455
  if (ctx) updateStatus(ctx);
@@ -519,6 +571,63 @@ export function createPlanModeController(pi: ExtensionAPI) {
519
571
  activeProgressWidget?.updateRoleTranscript("Implement", implementationMessages);
520
572
  }
521
573
  },
574
+ onAgentBeforeSettle: (event: AgentBeforeSettleEvent, ctx: ExtensionContext) => {
575
+ try {
576
+ const branch = readBranchEntries(ctx);
577
+ const persistedOmissions = new Set(
578
+ branch
579
+ .filter((entry) => entry.type === "context_edit" && entry.replacement === null && typeof entry.targetId === "string")
580
+ .map((entry) => entry.targetId as string),
581
+ );
582
+ // A different extension can invalidate the combined boundary proposal.
583
+ // Forget locally proposed edits that did not reach the branch so they are
584
+ // retried rather than suppressed for the rest of this process.
585
+ for (const id of omittedEntryIds) {
586
+ if (!persistedOmissions.has(id)) omittedEntryIds.delete(id);
587
+ }
588
+ const drafts: SessionBoundaryDraft[] = [];
589
+
590
+ // Persist plan-instruction omissions after plan mode exits. This is
591
+ // additive to onContext's per-request filter and repairs older sessions.
592
+ if (!planModeEnabled) {
593
+ drafts.push(...collectOmissionDrafts(branch, isPlanInstructionEntry, omittedEntryIds));
594
+ }
595
+ if (!verificationInFlight) {
596
+ drafts.push(...collectOmissionDrafts(branch, isVerificationPendingEntry, omittedEntryIds));
597
+ }
598
+
599
+ const stopReason = lastImplementationStopReason;
600
+ // KEEP IN LOCKSTEP with onAgentSettled's verification branch below.
601
+ // exit_plan_mode marks implementation pending before the follow-up has
602
+ // run, so implementationTurns distinguishes that planning boundary.
603
+ const verificationWillDispatch = implementationPending
604
+ && implementationTurns > 0
605
+ && event.context.pendingMessages.length === 0
606
+ && event.outcome !== "aborted"
607
+ && event.outcome !== "error"
608
+ && !implementationTurnFailed(stopReason)
609
+ && stopReason !== "aborted"
610
+ && implementationHandoff !== undefined
611
+ && (implementationHandoff.verifier ?? loadMoaConfig().verifier) !== undefined;
612
+ if (verificationWillDispatch) {
613
+ drafts.push({
614
+ type: "custom_message",
615
+ customType: VERIFICATION_PENDING_CONTEXT_TYPE,
616
+ content: buildVerificationPendingMessage(implementationHandoff?.planFilePath),
617
+ display: false,
618
+ });
619
+ }
620
+
621
+ if (drafts.length === 0) return undefined;
622
+ for (const draft of drafts) {
623
+ if (draft.type === "context_edit") omittedEntryIds.add(draft.targetId);
624
+ }
625
+ return { entries: [...event.entries, ...drafts] };
626
+ } catch {
627
+ // A thrown boundary handler could reject every proposed entry.
628
+ return undefined;
629
+ }
630
+ },
522
631
  onAgentSettled: (_event: AgentSettledEvent, ctx: ExtensionContext) => {
523
632
  updateStatus(ctx);
524
633
  if (!implementationPending) return;
@@ -566,10 +675,13 @@ export function createPlanModeController(pi: ExtensionAPI) {
566
675
  // explicit guard is required before the verification branch.
567
676
  if (stopReason === "aborted") return;
568
677
  if (!implementationHandoff) return;
678
+ // KEEP IN LOCKSTEP with verificationWillDispatch in onAgentBeforeSettle.
569
679
  const verifier = implementationHandoff.verifier ?? loadMoaConfig().verifier;
570
680
  if (verifier) {
681
+ verificationInFlight = true;
571
682
  void runImplementationVerification(ctx, moaRunHost, { ...implementationHandoff, verifier }, report)
572
- .catch(reportFlowFailure("Verification"));
683
+ .catch(reportFlowFailure("Verification"))
684
+ .finally(() => { verificationInFlight = false; });
573
685
  }
574
686
  },
575
687
  onContext: async (event: { messages: AgentMessage[] }) => {
@@ -578,9 +690,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
578
690
  const msg = message as AgentMessage & { customType?: string };
579
691
  if (msg.customType === PLAN_MODE_CONTEXT_TYPE || msg.customType === PLAN_EXIT_CONTEXT_TYPE) return false;
580
692
  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;
693
+ return !isPlanModeInstructionText(msg.content);
584
694
  }) };
585
695
  },
586
696
  onBeforeAgentStart: async () => {
@@ -612,6 +722,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
612
722
  activeCancelSession = undefined;
613
723
  activeObserveSession = undefined;
614
724
  implementationHandoff = undefined;
725
+ verificationInFlight = false;
726
+ omittedEntryIds.clear();
615
727
  footerError = undefined;
616
728
  lastRenderedFooter = undefined;
617
729
  lastFooterCtx = undefined;
@@ -625,6 +737,7 @@ export function createPlanModeController(pi: ExtensionAPI) {
625
737
  resetPlanSlug();
626
738
  if (pi.getFlag("mf-plan") === true) planModeEnabled = true;
627
739
  const restored = latestPlanModeStateEntry(ctx.sessionManager.getEntries());
740
+ const restoredPlanModeWasEnabled = restored?.enabled === true;
628
741
  if (restored) {
629
742
  planModeEnabled = restored.enabled ?? planModeEnabled;
630
743
  needsExitReminder = restored.needsExitReminder ?? false;
@@ -632,13 +745,31 @@ export function createPlanModeController(pi: ExtensionAPI) {
632
745
  planRepoSlug = isValidPlanSlug(restored.repoPlanSlug) ? restored.repoPlanSlug : undefined;
633
746
  activeRunMoaInfo = validateMfPlanInfo(restored.moaInfo);
634
747
  implementationHandoff = validateImplementationHandoff(restored.implementationHandoff);
748
+ toolsBeforePlanMode = validateToolLoadoutSnapshot(restored.toolsBeforePlanMode);
635
749
  }
636
750
  if (planModeEnabled) {
637
751
  lastReentryState = getPlan() !== null;
752
+ // A persisted plan-mode session had its loadout restored from the transcript
753
+ // before this handler ran (#9548), so the active set is the plan loadout, not
754
+ // the user's. Only the persisted snapshot or the registered loadout can be
755
+ // trusted here. The flag-only path (no entry) is excluded: there plan mode
756
+ // never ran, so the active set really is the user's.
757
+ const repairedLoadoutSnapshot = restoredPlanModeWasEnabled && toolsBeforePlanMode === undefined;
758
+ if (repairedLoadoutSnapshot) toolsBeforePlanMode = fullLoadoutFallback();
638
759
  enablePlanModeTools();
760
+ // Re-persist the restored (and, if needed, repaired) snapshot so later
761
+ // resumes of this session see the same trustworthy loadout.
762
+ if (restoredPlanModeWasEnabled) persistState();
639
763
  } else {
640
764
  deactivatePlanOnlyTools();
641
765
  }
766
+ try {
767
+ if (collectOmissionDrafts(readBranchEntries(ctx), isVerificationPendingEntry, new Set()).length > 0) {
768
+ ctx.ui.notify("The previous session ended before plan verification finished — the last implementation is unverified. Run /mf-plan-implement to verify it.", "warning");
769
+ }
770
+ } catch {
771
+ // A stale session-start context cannot be notified safely.
772
+ }
642
773
  updateStatus(ctx);
643
774
  startFooterTicker(ctx);
644
775
  if (ctx.mode === "tui" && typeof ctx.ui.onTerminalInput === "function") {
@@ -653,6 +784,8 @@ export function createPlanModeController(pi: ExtensionAPI) {
653
784
  },
654
785
  onSessionShutdown: (_event: { reason: string }) => {
655
786
  implementationPending = false;
787
+ verificationInFlight = false;
788
+ omittedEntryIds.clear();
656
789
  lastImplementationStopReason = undefined;
657
790
  lastImplementationReport = undefined;
658
791
  resetImplementationTranscript();
@@ -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
@@ -17,3 +17,4 @@ export const PLAN_ONLY_REGISTERED_TOOLS = ["write_plan", "exit_plan_mode", "mf_p
17
17
  export const PLAN_SUBAGENT_NAMES = new Set(["moa-explore", "mf-plan"]);
18
18
  export const PLAN_MODE_CONTEXT_TYPE = "mf-plan-context";
19
19
  export const PLAN_EXIT_CONTEXT_TYPE = "mf-plan-exit";
20
+ export const VERIFICATION_PENDING_CONTEXT_TYPE = "mf-plan-verification-pending";
@@ -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
  });
@@ -92,6 +92,24 @@ export function getResultOutput(result: { exitCode: number; stopReason?: string;
92
92
  return getFinalOutput(result.messages) || "(no output)";
93
93
  }
94
94
 
95
+ /**
96
+ * Longest failure message kept for a status row. Generous enough that a
97
+ * provider's JSON error payload survives whole for a progress table to wrap
98
+ * across several lines, bounded so a multi-KB stderr dump cannot crowd out the
99
+ * table it renders in.
100
+ */
101
+ const MAX_STATUS_DETAIL_CHARS = 500;
102
+
103
+ /**
104
+ * Collapse an agent failure into one bounded line for a progress row. Runs of
105
+ * whitespace — newlines included — are flattened because the row is laid out as
106
+ * a single line before its surface wraps it to the available width.
107
+ */
108
+ export function statusDetail(output: string): string {
109
+ const collapsed = output.replace(/\s+/g, " ").trim();
110
+ return collapsed.length <= MAX_STATUS_DETAIL_CHARS ? collapsed : `${collapsed.slice(0, MAX_STATUS_DETAIL_CHARS - 1)}…`;
111
+ }
112
+
95
113
  export function truncateOutput(output: string): string {
96
114
  const maxBytes = 50 * 1024;
97
115
  const bytes = Buffer.from(output, "utf8");
@@ -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));