@underactive/pi-topping-moa-fusion 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -2
- package/package.json +5 -5
- package/src/agents/authoritative.ts +48 -6
- package/src/agents/defaults.ts +2 -2
- package/src/moa/implementationRetry.ts +12 -5
- package/src/moa/reviewLoop.ts +7 -6
- package/src/moa/verification.ts +18 -8
- package/src/planning/planMode.ts +3 -0
- package/src/planning/tools/mfPlanSubagent.ts +1 -0
- package/src/ui/moaProgressWidget.ts +46 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.2]
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Verified compatibility with pi 1.0.0: no pi export this extension imports was removed or changed, and typecheck and the full test suite pass against 1.0.0.
|
|
10
|
+
- Updated all four pi development dependencies from 0.99.2 to 1.0.0.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- The MoA progress footer now keeps one cumulative elapsed time across fan-out, synthesis, implementation, verification, and verifier-driven repair rounds, while excluding time spent waiting for user input.
|
|
15
|
+
- Other sessions no longer run `moa-verifier` as a general-purpose subagent. Earlier versions installed `moa-proposer`, `moa-synthesizer`, and `moa-verifier` into `~/.pi/agent/agents/`, which subagent tools such as pi-subagents' `Agent` scan in every session, so a session outside `/mf-plan` could hand the verifier unrelated work. These three agents are no longer installed, and copies left by earlier versions are removed at session start. MoA runs already loaded them from the extension package, so edits to those copies never took effect.
|
|
16
|
+
- The `mf_plan_subagent` tool description now states its structured result shape, including the fields of each `results` entry. pi 1.0.0 codemode shortens each tool's declared output to one line of top-level field names, which left `results` as a bare name; the tool description is the one part of that summary kept verbatim, so script authors see the nested shape without calling `describeTool()`. The structured result itself is unchanged.
|
|
17
|
+
|
|
5
18
|
## [0.4.1]
|
|
6
19
|
|
|
7
20
|
### Added
|
package/README.md
CHANGED
|
@@ -99,7 +99,7 @@ Several safeguards target known LLM judging biases:
|
|
|
99
99
|
- **Auditable reasoning** — the plan opens with a Context section recording dimension reasoning, proposer alignment, and synthesis decisions, so recombination choices are inspectable. `### Proposer alignment` labels support as evidence-backed or asserted, and conflict recommendation Details lines include distinct-model support counts. Reconciliation commentary is confined there; the rest reads as one coherent plan. The orchestrator verifies all three subsections are present and rejects a first plan that omits them (one corrective retry, then a warning), the same way it enforces `## Proposer Verdicts`.
|
|
100
100
|
- **Disagreements go to the user** — every substantive disagreement must be emitted as a `## Conflicts` block, even when the synthesizer is confident, so its judgment is a default you can override rather than a decision made for you. Genuine ambiguity about *your intent* instead produces a single `## Open Question`, and the synthesizer is re-run with your answer.
|
|
101
101
|
|
|
102
|
-
**Protocol-internal, always current at runtime.** The proposer, synthesizer, and verifier prompts are tightly coupled to this extension's parsers and UI (e.g. the `## Conflicts` markup, and the verifier's `**Verdict:**`/`### Gaps` markup).
|
|
102
|
+
**Protocol-internal, always current at runtime.** The proposer, synthesizer, and verifier prompts are tightly coupled to this extension's parsers and UI (e.g. the `## Conflicts` markup, and the verifier's `**Verdict:**`/`### Gaps` markup). MoA runs always load them from the installed extension's bundled `agents/` directory, so a protocol update can never desync from what the subprocess runs. They are never installed into `~/.pi/agent/agents/`: subagent tools in every session scan that directory and would offer them as general-purpose agents outside `/mf-plan`. Copies left there by earlier versions are removed at session start. `moa-explore` and `mf-plan` remain fully user-customizable.
|
|
103
103
|
|
|
104
104
|
### Implementation failures
|
|
105
105
|
|
|
@@ -234,7 +234,7 @@ All four tools declare pi 0.99 `annotations`, and none claims to be read-only: `
|
|
|
234
234
|
|
|
235
235
|
## Subagents
|
|
236
236
|
|
|
237
|
-
Seven agent definitions are auto-installed to `~/.pi/agent/agents/` on first run (won't overwrite existing files).
|
|
237
|
+
Seven agent definitions ship with the extension. Two belong to the single-model workflow, three to MoA planning, one to the independent opinion flow, and one to the debate flow. The four outside MoA planning (`moa-explore`, `mf-plan`, `moa-opinion`, `moa-debater`) are auto-installed to `~/.pi/agent/agents/` on first run (won't overwrite existing files). The three MoA agents always load from the extension itself and are never installed (see [How the synthesizer works](#how-the-synthesizer-works)):
|
|
238
238
|
|
|
239
239
|
| Agent | Workflow | Role | Model & thinking come from |
|
|
240
240
|
|-------|----------|------|----------------------------|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@underactive/pi-topping-moa-fusion",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Pi extension implementing /mf-plan — Claude Code-style 5-phase plan mode with a Mixture-of-Agents proposer/synthesizer pipeline.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -50,10 +50,10 @@
|
|
|
50
50
|
"typebox": "*"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
|
-
"@earendil-works/pi-agent-core": "0.
|
|
54
|
-
"@earendil-works/pi-ai": "0.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
56
|
-
"@earendil-works/pi-tui": "0.
|
|
53
|
+
"@earendil-works/pi-agent-core": "1.0.0",
|
|
54
|
+
"@earendil-works/pi-ai": "1.0.0",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "1.0.0",
|
|
56
|
+
"@earendil-works/pi-tui": "1.0.0",
|
|
57
57
|
"@types/node": "^24.0.0",
|
|
58
58
|
"typebox": "^1.3.3",
|
|
59
59
|
"typescript": "^5.8.0"
|
|
@@ -15,6 +15,14 @@ import { parseAgentFile, type AgentConfig } from "./discovery.ts";
|
|
|
15
15
|
*/
|
|
16
16
|
export const AUTHORITATIVE_MOA_AGENT_NAMES = Object.freeze(["moa-proposer", "moa-synthesizer", "moa-verifier"]);
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* The user-facing agents seeded into `~/.pi/agent/agents/` for customization.
|
|
20
|
+
* That directory is shared: every subagent tool scans it in every session
|
|
21
|
+
* (e.g. pi-subagents' `Agent`), so anything installed there is offered as a
|
|
22
|
+
* general-purpose agent outside `/mf-plan`.
|
|
23
|
+
*/
|
|
24
|
+
const INSTALLED_AGENT_NAMES = Object.freeze(["moa-explore", "mf-plan", "moa-opinion", "moa-debater"]);
|
|
25
|
+
|
|
18
26
|
const MAX_WALK_UP_LEVELS = 5;
|
|
19
27
|
|
|
20
28
|
/**
|
|
@@ -67,19 +75,22 @@ export function withAuthoritativeMoaAgents(agents: AgentConfig[], shippedDir: st
|
|
|
67
75
|
return result;
|
|
68
76
|
}
|
|
69
77
|
|
|
78
|
+
/**
|
|
79
|
+
* Seed the user-facing agents (no-clobber) and remove the protocol-internal
|
|
80
|
+
* copies earlier versions installed alongside them. MoA runs load the
|
|
81
|
+
* protocol agents from the bundled `agents/` directory instead — see
|
|
82
|
+
* withAuthoritativeMoaAgents.
|
|
83
|
+
*/
|
|
70
84
|
export function installShippedAgents(): void {
|
|
71
85
|
const agentsDir = path.join(getAgentDir(), "agents");
|
|
72
86
|
fs.mkdirSync(agentsDir, { recursive: true });
|
|
73
87
|
|
|
74
|
-
// This still writes user-visible files for direct inspection/customization,
|
|
75
|
-
// but note `moa-proposer`/`moa-synthesizer` are no longer authoritative at
|
|
76
|
-
// runtime once written here — see withAuthoritativeMoaAgents.
|
|
77
88
|
const sourceDir = shippedAgentsDir();
|
|
78
|
-
for (const name of
|
|
79
|
-
const targetPath = path.join(agentsDir, name);
|
|
89
|
+
for (const name of INSTALLED_AGENT_NAMES) {
|
|
90
|
+
const targetPath = path.join(agentsDir, `${name}.md`);
|
|
80
91
|
if (fs.existsSync(targetPath)) continue; // don't clobber
|
|
81
92
|
|
|
82
|
-
const sourcePath = path.join(sourceDir, name);
|
|
93
|
+
const sourcePath = path.join(sourceDir, `${name}.md`);
|
|
83
94
|
if (fs.existsSync(sourcePath)) {
|
|
84
95
|
try {
|
|
85
96
|
fs.copyFileSync(sourcePath, targetPath);
|
|
@@ -88,4 +99,35 @@ export function installShippedAgents(): void {
|
|
|
88
99
|
}
|
|
89
100
|
}
|
|
90
101
|
}
|
|
102
|
+
|
|
103
|
+
removeInstalledAuthoritativeAgents(agentsDir, sourceDir);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Delete installed copies of the protocol-internal agents from `agentsDir`.
|
|
108
|
+
* Other sessions' subagent tools picked them up there (`moa-verifier` ran for
|
|
109
|
+
* unrelated verification work), and MoA runs never read them. Only a file that
|
|
110
|
+
* still declares the agent's own name is removed, and nothing is removed when
|
|
111
|
+
* `agentsDir` resolves to `shippedDir`, whose files are the bundled definitions.
|
|
112
|
+
*/
|
|
113
|
+
export function removeInstalledAuthoritativeAgents(agentsDir: string, shippedDir: string): void {
|
|
114
|
+
if (isSameDirectory(agentsDir, shippedDir)) return;
|
|
115
|
+
|
|
116
|
+
for (const name of AUTHORITATIVE_MOA_AGENT_NAMES) {
|
|
117
|
+
const targetPath = path.join(agentsDir, `${name}.md`);
|
|
118
|
+
try {
|
|
119
|
+
if (parseAgentFile(targetPath, "user")?.name !== name) continue;
|
|
120
|
+
fs.unlinkSync(targetPath);
|
|
121
|
+
} catch {
|
|
122
|
+
// Malformed frontmatter, or another session removed it first.
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isSameDirectory(a: string, b: string): boolean {
|
|
128
|
+
try {
|
|
129
|
+
return fs.realpathSync(a) === fs.realpathSync(b);
|
|
130
|
+
} catch {
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
91
133
|
}
|
package/src/agents/defaults.ts
CHANGED
|
@@ -22,8 +22,8 @@ export interface ConfigurableAgent {
|
|
|
22
22
|
/**
|
|
23
23
|
* The shipped agents whose model the user may choose. `moa-proposer` and
|
|
24
24
|
* `moa-synthesizer` are excluded: their model is assigned per slot at runtime,
|
|
25
|
-
* and
|
|
26
|
-
*
|
|
25
|
+
* and they load from the bundled definitions without ever being installed, so
|
|
26
|
+
* there is no on-disk frontmatter to write. `mf-plan` is excluded
|
|
27
27
|
* too: it follows the session model chosen when entering plan mode, so a
|
|
28
28
|
* configured default would never be read.
|
|
29
29
|
*/
|
|
@@ -221,10 +221,14 @@ export async function runImplementationRetryFlow(
|
|
|
221
221
|
"Continue manually",
|
|
222
222
|
];
|
|
223
223
|
|
|
224
|
-
const
|
|
224
|
+
const widget = host.getActiveProgressWidget();
|
|
225
|
+
const selection = await (widget?.whileWaitingForUser?.(() => ctx.ui.select(
|
|
225
226
|
"Implementation failed — the plan is still available. What next?",
|
|
226
227
|
options,
|
|
227
|
-
)
|
|
228
|
+
)) ?? ctx.ui.select(
|
|
229
|
+
"Implementation failed — the plan is still available. What next?",
|
|
230
|
+
options,
|
|
231
|
+
));
|
|
228
232
|
|
|
229
233
|
if (!selection || selection === "Continue manually") {
|
|
230
234
|
host.stopActiveProgressWidget();
|
|
@@ -246,11 +250,15 @@ export async function runImplementationRetryFlow(
|
|
|
246
250
|
}
|
|
247
251
|
|
|
248
252
|
if (selection === "Choose a different model") {
|
|
249
|
-
const newSelection = await showImplementingModelPicker(
|
|
253
|
+
const newSelection = await (widget?.whileWaitingForUser?.(() => showImplementingModelPicker(
|
|
250
254
|
ctx,
|
|
251
255
|
host.currentThinkingLevel(),
|
|
252
256
|
"Implementation failed — choose a different model",
|
|
253
|
-
)
|
|
257
|
+
)) ?? showImplementingModelPicker(
|
|
258
|
+
ctx,
|
|
259
|
+
host.currentThinkingLevel(),
|
|
260
|
+
"Implementation failed — choose a different model",
|
|
261
|
+
));
|
|
254
262
|
|
|
255
263
|
if (!newSelection) {
|
|
256
264
|
// User cancelled model picker; re-run retry flow
|
|
@@ -271,7 +279,6 @@ export async function runImplementationRetryFlow(
|
|
|
271
279
|
host.setImplementationHandoff(updatedHandoff);
|
|
272
280
|
host.markImplementationPending(ctx);
|
|
273
281
|
|
|
274
|
-
const widget = host.getActiveProgressWidget();
|
|
275
282
|
if (widget) {
|
|
276
283
|
const moaInfo = host.getActiveRunMoaInfo();
|
|
277
284
|
widget.setPhaseModels({
|
package/src/moa/reviewLoop.ts
CHANGED
|
@@ -32,27 +32,28 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
|
|
|
32
32
|
});
|
|
33
33
|
host.persistState();
|
|
34
34
|
let chatRounds = 0;
|
|
35
|
+
const waitForUser = <T>(task: () => Promise<T>): Promise<T> => widget.whileWaitingForUser?.(task) ?? task();
|
|
35
36
|
|
|
36
37
|
while (true) {
|
|
37
38
|
session.closeOverlay?.();
|
|
38
39
|
const synthesizer = getSynthesizer();
|
|
39
40
|
let decision: PlanReviewDecision;
|
|
40
41
|
if (ctx.mode === "tui") {
|
|
41
|
-
decision = await showPlanReview(
|
|
42
|
+
decision = await waitForUser(() => showPlanReview(
|
|
42
43
|
ctx,
|
|
43
44
|
currentPlan,
|
|
44
45
|
{ proposers, synthesizer, proposerPlans, verdictsMarkdown: getLatestVerdicts() ?? undefined },
|
|
45
46
|
host.getPlanRepoSlug(),
|
|
46
47
|
chatRounds < MAX_REVIEW_CHAT_ROUNDS,
|
|
47
|
-
);
|
|
48
|
+
));
|
|
48
49
|
} else {
|
|
49
|
-
const choice = await ctx.ui.select("Exit plan mode?", ["Approve — start implementing", "Keep planning", "Edit plan", "Chat with synthesizer"]);
|
|
50
|
+
const choice = await waitForUser(() => ctx.ui.select("Exit plan mode?", ["Approve — start implementing", "Keep planning", "Edit plan", "Chat with synthesizer"]));
|
|
50
51
|
decision = choice?.startsWith("Approve") ? "approve" : choice?.startsWith("Edit") ? "edit" : choice?.startsWith("Chat") ? "chat" : "keep";
|
|
51
52
|
}
|
|
52
53
|
|
|
53
54
|
if (decision === "edit") {
|
|
54
55
|
session.closeOverlay?.();
|
|
55
|
-
const edited = await ctx.ui.editor("Edit Plan", currentPlan);
|
|
56
|
+
const edited = await waitForUser(() => ctx.ui.editor("Edit Plan", currentPlan));
|
|
56
57
|
if (edited?.trim()) {
|
|
57
58
|
currentPlan = stripSynthSections(edited.trim());
|
|
58
59
|
writePlan(currentPlan);
|
|
@@ -68,7 +69,7 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
|
|
|
68
69
|
continue;
|
|
69
70
|
}
|
|
70
71
|
session.closeOverlay?.();
|
|
71
|
-
const feedback = await ctx.ui.editor("Describe the changes you want:");
|
|
72
|
+
const feedback = await waitForUser(() => ctx.ui.editor("Describe the changes you want:"));
|
|
72
73
|
if (!feedback?.trim()) continue;
|
|
73
74
|
chatRounds++;
|
|
74
75
|
synthConversation.push(`The user reviewed your synthesized plan and gave this feedback:\n${feedback.trim()}\n\nRevise the plan accordingly and re-emit the complete final plan. Drop any ## Conflicts or ## Open Question sections.`);
|
|
@@ -121,7 +122,7 @@ export async function runReviewLoop(options: ReviewLoopOptions): Promise<"done">
|
|
|
121
122
|
if (roles?.implementer) {
|
|
122
123
|
implementationSelection = { ref: roles.implementer, thinking: roles.implementerThinking ?? host.currentThinkingLevel() };
|
|
123
124
|
} else {
|
|
124
|
-
implementationSelection = await showImplementingModelPicker(ctx, host.currentThinkingLevel());
|
|
125
|
+
implementationSelection = await waitForUser(() => showImplementingModelPicker(ctx, host.currentThinkingLevel()));
|
|
125
126
|
}
|
|
126
127
|
if (implementationSelection) {
|
|
127
128
|
await host.applyImplementingSelection(
|
package/src/moa/verification.ts
CHANGED
|
@@ -471,12 +471,13 @@ export async function runVerificationDecision(deps: {
|
|
|
471
471
|
? ["View full findings", "Send verifier findings to the implementer", "Accept implementation as-is"]
|
|
472
472
|
: ["Send verifier findings to the implementer", "Accept implementation as-is"];
|
|
473
473
|
while (true) {
|
|
474
|
-
const
|
|
474
|
+
const selectDecision = () => ctx.ui.select(
|
|
475
475
|
buildVerificationDecisionPrompt(parsed, scriptResults, reportPath, promptWidth),
|
|
476
476
|
options,
|
|
477
477
|
);
|
|
478
|
+
const choice = await (widget?.whileWaitingForUser?.(selectDecision) ?? selectDecision());
|
|
478
479
|
if (choice === "View full findings") {
|
|
479
|
-
await showFindings(ctx, report);
|
|
480
|
+
await (widget?.whileWaitingForUser?.(() => showFindings(ctx, report)) ?? showFindings(ctx, report));
|
|
480
481
|
continue;
|
|
481
482
|
}
|
|
482
483
|
if (choice === "Send verifier findings to the implementer") {
|
|
@@ -691,10 +692,13 @@ export async function runImplementationVerification(
|
|
|
691
692
|
}
|
|
692
693
|
|
|
693
694
|
if (outcome.cancelled) {
|
|
694
|
-
const choice = await ctx.ui.select(
|
|
695
|
+
const choice = await (widget?.whileWaitingForUser?.(() => ctx.ui.select(
|
|
695
696
|
"Verification was cancelled — what next?",
|
|
696
697
|
["Retry verification", "Skip verification"],
|
|
697
|
-
)
|
|
698
|
+
)) ?? ctx.ui.select(
|
|
699
|
+
"Verification was cancelled — what next?",
|
|
700
|
+
["Retry verification", "Skip verification"],
|
|
701
|
+
));
|
|
698
702
|
if (choice === "Retry verification") return "retry";
|
|
699
703
|
ctx.ui.notify("Verification skipped. Implementation left as-is.", "warning");
|
|
700
704
|
finish("cancelled");
|
|
@@ -715,10 +719,13 @@ export async function runImplementationVerification(
|
|
|
715
719
|
const options = fallbackOption
|
|
716
720
|
? [fallbackOption, "Retry verification", "Skip verification"]
|
|
717
721
|
: ["Retry verification", "Skip verification"];
|
|
718
|
-
const choice = await ctx.ui.select(
|
|
722
|
+
const choice = await (widget?.whileWaitingForUser?.(() => ctx.ui.select(
|
|
719
723
|
`Verification could not complete (${verifierLabel}): ${titleReason}`,
|
|
720
724
|
options,
|
|
721
|
-
)
|
|
725
|
+
)) ?? ctx.ui.select(
|
|
726
|
+
`Verification could not complete (${verifierLabel}): ${titleReason}`,
|
|
727
|
+
options,
|
|
728
|
+
));
|
|
722
729
|
if (fallbackOption && choice === fallbackOption) return "fallback";
|
|
723
730
|
if (choice === "Retry verification") return "retry";
|
|
724
731
|
ctx.ui.notify("Verification skipped. Implementation left as-is.", "warning");
|
|
@@ -788,10 +795,13 @@ export async function runImplementationVerification(
|
|
|
788
795
|
finish("error");
|
|
789
796
|
return;
|
|
790
797
|
}
|
|
791
|
-
const choice = await ctx.ui.select(
|
|
798
|
+
const choice = await (widget?.whileWaitingForUser?.(() => ctx.ui.select(
|
|
792
799
|
"The verifier produced no parseable verdict — what next?",
|
|
793
800
|
["Retry verification", "Skip verification"],
|
|
794
|
-
)
|
|
801
|
+
)) ?? ctx.ui.select(
|
|
802
|
+
"The verifier produced no parseable verdict — what next?",
|
|
803
|
+
["Retry verification", "Skip verification"],
|
|
804
|
+
));
|
|
795
805
|
if (choice === "Retry verification") continue;
|
|
796
806
|
ctx.ui.notify("Verification inconclusive — implementation left as-is.", "warning");
|
|
797
807
|
finish("error");
|
package/src/planning/planMode.ts
CHANGED
|
@@ -154,6 +154,9 @@ export function createPlanModeController(pi: ExtensionAPI) {
|
|
|
154
154
|
// than a module singleton so the fake-`pi` tests stay self-contained and no
|
|
155
155
|
// state leaks across sessions.
|
|
156
156
|
const askUserQuestionTracker = createAskUserQuestionTracker(pi, () => {
|
|
157
|
+
const widget = runningProgressWidget ?? activeProgressWidget;
|
|
158
|
+
if (askUserQuestionTracker.isActive()) widget?.pauseElapsed();
|
|
159
|
+
else widget?.resumeElapsed();
|
|
157
160
|
if (lastFooterCtx) updateStatus(lastFooterCtx);
|
|
158
161
|
});
|
|
159
162
|
// Subscribe immediately so the blocked flag is live from extension load, not
|
|
@@ -61,6 +61,7 @@ export function registerMfPlanSubagentTool(pi: ExtensionAPI, host: MfPlanSubagen
|
|
|
61
61
|
"Modes: single (agent + task), parallel (tasks array).",
|
|
62
62
|
"Agents: moa-explore (fast codebase recon), mf-plan (implementation planning).",
|
|
63
63
|
`Default scope is "user" (from ${path.join(getAgentDir(), "agents")}).`,
|
|
64
|
+
"Returns { mode, status, total, succeeded, failed, cancelled, aborted, results }; results is an array of { agent, status, exitCode, stopReason?, model?, output, truncated, usage }.",
|
|
64
65
|
].join(" "),
|
|
65
66
|
|
|
66
67
|
outputSchema: MF_PLAN_SUBAGENT_OUTPUT_SCHEMA,
|
|
@@ -215,6 +215,8 @@ export interface MoaProgressView {
|
|
|
215
215
|
phaseModels(): PhaseModels;
|
|
216
216
|
/** The phase currently highlighted in the band; undefined highlights none. */
|
|
217
217
|
activePhase(): MoaPhase | undefined;
|
|
218
|
+
/** Total active run time, excluding periods paused for user input. */
|
|
219
|
+
totalElapsedMs(now?: number): number;
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
export interface MoaProgressCallbacks {
|
|
@@ -258,6 +260,12 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
258
260
|
private tableMounted = false;
|
|
259
261
|
private showPreview = true;
|
|
260
262
|
private requestRender: (() => void) | undefined;
|
|
263
|
+
/** Accumulated active run time before the current active interval. */
|
|
264
|
+
private elapsedActiveMs = 0;
|
|
265
|
+
/** Start of the current active interval; undefined while waiting for user input. */
|
|
266
|
+
private elapsedActiveSince: number | undefined;
|
|
267
|
+
/** Nested pause ownership (table unmount plus an explicit prompt may overlap). */
|
|
268
|
+
private elapsedPauseDepth = 0;
|
|
261
269
|
|
|
262
270
|
constructor(
|
|
263
271
|
ctx: ExtensionContext,
|
|
@@ -288,6 +296,8 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
288
296
|
|
|
289
297
|
private mountTable(): void {
|
|
290
298
|
if (this.tableMounted) return;
|
|
299
|
+
if (this.elapsedActiveSince === undefined && this.elapsedPauseDepth === 0) this.elapsedActiveSince = Date.now();
|
|
300
|
+
else this.resumeElapsed();
|
|
291
301
|
this.tableMounted = true;
|
|
292
302
|
this.ui.setWidget(WIDGET_KEY, (tui, theme) => {
|
|
293
303
|
this.requestRender = () => tui.requestRender();
|
|
@@ -297,6 +307,7 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
297
307
|
|
|
298
308
|
private unmount(): void {
|
|
299
309
|
if (this.tableMounted) {
|
|
310
|
+
this.pauseElapsed();
|
|
300
311
|
this.tableMounted = false;
|
|
301
312
|
this.callbacks.closeStacked?.();
|
|
302
313
|
}
|
|
@@ -311,6 +322,9 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
311
322
|
*/
|
|
312
323
|
startFanout(refs: ModelRef[], thinking: (ThinkingLevel | undefined)[] = [], maxConcurrency = refs.length): void {
|
|
313
324
|
const startedAt = Date.now();
|
|
325
|
+
this.elapsedActiveMs = 0;
|
|
326
|
+
this.elapsedActiveSince = startedAt;
|
|
327
|
+
this.elapsedPauseDepth = 0;
|
|
314
328
|
const activeCount = Number.isFinite(maxConcurrency) ? Math.max(1, Math.floor(maxConcurrency)) : refs.length;
|
|
315
329
|
this.statuses = refs.map((ref, index) => ({
|
|
316
330
|
ref,
|
|
@@ -341,6 +355,35 @@ export class MoaProgressWidget implements MoaProgressView {
|
|
|
341
355
|
return this.active;
|
|
342
356
|
}
|
|
343
357
|
|
|
358
|
+
/** Pause the run clock while a prompt, picker, or questionnaire waits on the user. */
|
|
359
|
+
pauseElapsed(now = Date.now()): void {
|
|
360
|
+
this.elapsedPauseDepth++;
|
|
361
|
+
if (this.elapsedPauseDepth !== 1 || this.elapsedActiveSince === undefined) return;
|
|
362
|
+
this.elapsedActiveMs += Math.max(0, now - this.elapsedActiveSince);
|
|
363
|
+
this.elapsedActiveSince = undefined;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Resume one pause owner; the clock restarts after the outermost wait ends. */
|
|
367
|
+
resumeElapsed(now = Date.now()): void {
|
|
368
|
+
if (this.elapsedPauseDepth === 0) return;
|
|
369
|
+
this.elapsedPauseDepth--;
|
|
370
|
+
if (this.elapsedPauseDepth === 0) this.elapsedActiveSince = now;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Run one user interaction without charging its wall time to the run total. */
|
|
374
|
+
async whileWaitingForUser<T>(task: () => Promise<T>): Promise<T> {
|
|
375
|
+
this.pauseElapsed();
|
|
376
|
+
try {
|
|
377
|
+
return await task();
|
|
378
|
+
} finally {
|
|
379
|
+
this.resumeElapsed();
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
totalElapsedMs(now = Date.now()): number {
|
|
384
|
+
return this.elapsedActiveMs + (this.elapsedActiveSince === undefined ? 0 : Math.max(0, now - this.elapsedActiveSince));
|
|
385
|
+
}
|
|
386
|
+
|
|
344
387
|
phaseLabel(phase: MoaPhase): string {
|
|
345
388
|
return this.callbacks.phaseLabels?.[phase] ?? phase;
|
|
346
389
|
}
|
|
@@ -671,7 +714,7 @@ export class MoaProgressTableComponent implements Component {
|
|
|
671
714
|
private readonly timer: ReturnType<typeof setInterval>;
|
|
672
715
|
private readonly meters: RowMeter[] = [];
|
|
673
716
|
private readonly previewCache = new Map<number, { revision: number; width: number; lines: string[] }>();
|
|
674
|
-
private readonly
|
|
717
|
+
private readonly animationStartedAt = Date.now();
|
|
675
718
|
private spinFrame = 0;
|
|
676
719
|
|
|
677
720
|
constructor(tui: TUI, theme: ProgressTheme, view: MoaProgressView) {
|
|
@@ -760,7 +803,7 @@ export class MoaProgressTableComponent implements Component {
|
|
|
760
803
|
const paint = (lit: boolean, text: string): string => {
|
|
761
804
|
if (!lit) return th.fg("dim", text);
|
|
762
805
|
return th.getFgAnsi
|
|
763
|
-
? shimmerString(text, now - this.
|
|
806
|
+
? shimmerString(text, now - this.animationStartedAt, th as ShimmerTheme, "ltr", "normal", true)
|
|
764
807
|
: th.fg("text", text);
|
|
765
808
|
};
|
|
766
809
|
// Each boundary carries a two-row powerline chevron: the "\" half on the name
|
|
@@ -933,7 +976,7 @@ export class MoaProgressTableComponent implements Component {
|
|
|
933
976
|
}
|
|
934
977
|
|
|
935
978
|
const totalCost = rows.reduce((sum, r) => sum + (r.costUsd ?? 0), 0);
|
|
936
|
-
const totals = `total ${formatCost(totalCost)} · ${formatElapsed(
|
|
979
|
+
const totals = `total ${formatCost(totalCost)} · ${formatElapsed(this.view.totalElapsedMs(now))}`;
|
|
937
980
|
lines.push(border("─".repeat(innerWidth)));
|
|
938
981
|
lines.push(row(`${th.fg("dim", TABLE_FOOTER)}${" ".repeat(Math.max(1, bodyWidth - visibleWidth(TABLE_FOOTER) - visibleWidth(totals)))}${th.fg("dim", totals)}`));
|
|
939
982
|
lines.push(border("═".repeat(innerWidth)));
|