@underactive/pi-topping-moa-fusion 0.4.2 → 0.4.3
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
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.4.3]
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Verified compatibility with pi 1.0.3: no pi export this extension imports was removed or changed, and typecheck and the full test suite pass against 1.0.3.
|
|
10
|
+
- Updated all four pi development dependencies from 1.0.0 to 1.0.3.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- `/mf-plan` no longer aborts the whole run when a proposer's reply contains a NUL or other control character. Retry tasks now strip control characters (keeping newlines, tabs, and carriage returns) before quoting a previous reply, and accepted proposals are cleaned the same way before synthesis. If a subagent task still cannot be passed as a process argument, only that agent fails, with the reason shown in its progress row, and the remaining proposers continue.
|
|
15
|
+
- On pi 1.0.3, Home and End jump to the top and bottom of the verification findings popup again. pi 1.0.3 moved its `tui.altScreen.top`/`bottom` bindings to Ctrl+Home/Ctrl+End. The popup now accepts those bindings and plain Home/End, which matches the plan review, observer, and menu views. Its hint row shows the configured binding.
|
|
16
|
+
|
|
5
17
|
## [0.4.2]
|
|
6
18
|
|
|
7
19
|
### Changed
|
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.3",
|
|
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": "1.0.
|
|
54
|
-
"@earendil-works/pi-ai": "1.0.
|
|
55
|
-
"@earendil-works/pi-coding-agent": "1.0.
|
|
56
|
-
"@earendil-works/pi-tui": "1.0.
|
|
53
|
+
"@earendil-works/pi-agent-core": "1.0.3",
|
|
54
|
+
"@earendil-works/pi-ai": "1.0.3",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "1.0.3",
|
|
56
|
+
"@earendil-works/pi-tui": "1.0.3",
|
|
57
57
|
"@types/node": "^24.0.0",
|
|
58
58
|
"typebox": "^1.3.3",
|
|
59
59
|
"typescript": "^5.8.0"
|
package/src/moa/fanout.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { CancelSession } from "../runtime/cancelRun.ts";
|
|
|
5
5
|
import { CancelRun } from "../runtime/cancelRun.ts";
|
|
6
6
|
import { getFinalOutput, isFailedResult } from "../runtime/results.ts";
|
|
7
7
|
import type { ModelParallelAgentTask } from "../runtime/runner.ts";
|
|
8
|
+
import { stripControlCharacters } from "../shared/controlChars.ts";
|
|
8
9
|
import { modelRefLabel, TRIGGER_TURN, type ModelRef, type ThinkingLevel } from "../shared/modelRefs.ts";
|
|
9
10
|
import { activityLoopCount } from "../ui/agentStatus.ts";
|
|
10
11
|
import { showModelThinkingPicker } from "../ui/moaModelPicker.ts";
|
|
@@ -251,7 +252,7 @@ export async function runFanoutPhase(options: FanoutPhaseOptions): Promise<Fanou
|
|
|
251
252
|
const succeeded: SucceededProposal[] = [];
|
|
252
253
|
for (let index = 0; index < results.length; index++) {
|
|
253
254
|
const result = results[index];
|
|
254
|
-
if (!isFailedResult(result)) succeeded.push({ ref: proposers[index], plan: getFinalOutput(result.messages), originalIndex: index });
|
|
255
|
+
if (!isFailedResult(result)) succeeded.push({ ref: proposers[index], plan: stripControlCharacters(getFinalOutput(result.messages)), originalIndex: index });
|
|
255
256
|
}
|
|
256
257
|
if (succeeded.length === 0) {
|
|
257
258
|
widget.stopWidget();
|
package/src/moa/planlessRetry.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { stripControlCharacters } from "../shared/controlChars.ts";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Recovery for planning agents that exit cleanly without delivering a plan.
|
|
3
5
|
*
|
|
@@ -37,8 +39,11 @@ export function looksLikePlan(output: string): boolean {
|
|
|
37
39
|
export const PROPOSER_RETRY_HEADER =
|
|
38
40
|
"IMPORTANT: You already attempted this planning task once and stopped without emitting the required plan — your previous output (quoted below) ended in clarifying questions or was otherwise incomplete. You are running headless: there is no user and no way to receive answers. Do not ask questions and do not wait. Answer any open questions yourself with best-judgment assumptions, record them under \"## Open Questions / Assumptions\", and emit the COMPLETE plan in the required output format now.";
|
|
39
41
|
|
|
42
|
+
// Control characters are stripped before quoting: model replies can carry a
|
|
43
|
+
// NUL (pi's JSON wire format round-trips \u0000), and a NUL cannot cross the
|
|
44
|
+
// argv boundary the retry task is passed through.
|
|
40
45
|
export function buildRetryCorrection(header: string, previousOutput: string): string {
|
|
41
|
-
const previous = previousOutput.trim().replaceAll('"""', "''") || "(no output)";
|
|
46
|
+
const previous = stripControlCharacters(previousOutput).trim().replaceAll('"""', "''") || "(no output)";
|
|
42
47
|
return [header, "", "Your previous output:", '"""', previous, '"""'].join("\n");
|
|
43
48
|
}
|
|
44
49
|
|
package/src/runtime/runner.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* Ported from pi's subagent example.
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
6
|
+
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
|
7
|
+
import type { Readable } from "node:stream";
|
|
7
8
|
import * as fs from "node:fs";
|
|
8
9
|
import * as os from "node:os";
|
|
9
10
|
import * as path from "node:path";
|
|
@@ -47,6 +48,13 @@ export const READ_ONLY_SUBAGENT_ENV = Object.freeze({
|
|
|
47
48
|
/** Opts provider extensions into the stderr usage-beacon protocol. */
|
|
48
49
|
const USAGE_BEACON_SUBAGENT_ENV = Object.freeze({ PI_USAGE_BEACON: "1" });
|
|
49
50
|
|
|
51
|
+
function spawnFailureMessage(error: unknown): string {
|
|
52
|
+
const errorCode =
|
|
53
|
+
error && typeof error === "object" && "code" in error && typeof error.code === "string" ? error.code : undefined;
|
|
54
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
55
|
+
return `Failed to spawn subprocess${errorCode ? ` (${errorCode})` : ""}: ${message}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
50
58
|
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
51
59
|
const tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-topping-moa-fusion-"));
|
|
52
60
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
@@ -175,6 +183,18 @@ export async function runSingleAgent(
|
|
|
175
183
|
currentResult.stderr = `Task argument size: ${taskBytes} bytes (exceeds 128 KiB).\n`;
|
|
176
184
|
}
|
|
177
185
|
|
|
186
|
+
// argv entries are C strings: a NUL makes spawn throw before execve.
|
|
187
|
+
// Follow-up (out of scope): pass the task via temp file or stdin instead of
|
|
188
|
+
// argv, which would also remove the E2BIG class of failures.
|
|
189
|
+
if (task.includes("\0")) {
|
|
190
|
+
const message = "Task contains a NUL byte and cannot be passed as a process argument";
|
|
191
|
+
currentResult.exitCode = 1;
|
|
192
|
+
currentResult.stopReason = "error";
|
|
193
|
+
currentResult.errorMessage = message;
|
|
194
|
+
currentResult.stderr += `${message}\n`;
|
|
195
|
+
return currentResult;
|
|
196
|
+
}
|
|
197
|
+
|
|
178
198
|
const outputActivity = new OutputActivityTracker();
|
|
179
199
|
const partialMessage = new PartialAssistantAssembler();
|
|
180
200
|
|
|
@@ -202,15 +222,32 @@ export async function runSingleAgent(
|
|
|
202
222
|
|
|
203
223
|
const exitCode = await new Promise<number>((resolve) => {
|
|
204
224
|
const invocation = getPiInvocation(args);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
225
|
+
|
|
226
|
+
const appendDiagnostics = (diagnostics: string) => {
|
|
227
|
+
currentResult.stderr = (currentResult.stderr + diagnostics).slice(-MAX_DIAGNOSTIC_CHARS);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
let proc: ChildProcessByStdio<null, Readable, Readable>;
|
|
231
|
+
try {
|
|
232
|
+
proc = spawn(invocation.command, invocation.args, {
|
|
233
|
+
cwd: cwd ?? defaultCwd,
|
|
234
|
+
shell: false,
|
|
235
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
236
|
+
// Read-only handshake for agentic provider bridges (see
|
|
237
|
+
// READ_ONLY_SUBAGENT_ENV). The pi --tools allowlist above cannot
|
|
238
|
+
// restrain a bridged agent's own tools.
|
|
239
|
+
env: { ...process.env, ...READ_ONLY_SUBAGENT_ENV, ...USAGE_BEACON_SUBAGENT_ENV },
|
|
240
|
+
});
|
|
241
|
+
} catch (error) {
|
|
242
|
+
// Argument validation (NUL bytes, bad cwd) and errors such as E2BIG throw
|
|
243
|
+
// synchronously; only ENOENT/EACCES-class failures reach proc.on("error").
|
|
244
|
+
const spawnError = spawnFailureMessage(error);
|
|
245
|
+
currentResult.stopReason = "error";
|
|
246
|
+
currentResult.errorMessage = spawnError;
|
|
247
|
+
appendDiagnostics(`${spawnError}\n`);
|
|
248
|
+
resolve(1);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
214
251
|
trackedProcesses.add(proc);
|
|
215
252
|
|
|
216
253
|
// Auto-remove from tracking when the process exits (normal or otherwise).
|
|
@@ -228,10 +265,6 @@ export async function runSingleAgent(
|
|
|
228
265
|
};
|
|
229
266
|
resetIdleTimer();
|
|
230
267
|
|
|
231
|
-
const appendDiagnostics = (diagnostics: string) => {
|
|
232
|
-
currentResult.stderr = (currentResult.stderr + diagnostics).slice(-MAX_DIAGNOSTIC_CHARS);
|
|
233
|
-
};
|
|
234
|
-
|
|
235
268
|
const processLine = (line: string) => {
|
|
236
269
|
if (!line.trim()) return;
|
|
237
270
|
try {
|
|
@@ -359,8 +392,7 @@ export async function runSingleAgent(
|
|
|
359
392
|
|
|
360
393
|
proc.on("error", (error) => {
|
|
361
394
|
if (idleTimer) clearTimeout(idleTimer);
|
|
362
|
-
const
|
|
363
|
-
const spawnError = `Failed to spawn subprocess${errorCode ? ` (${errorCode})` : ""}: ${error.message}`;
|
|
395
|
+
const spawnError = spawnFailureMessage(error);
|
|
364
396
|
if (!currentResult.errorMessage) currentResult.errorMessage = spawnError;
|
|
365
397
|
if (!currentResult.stderr.includes(spawnError)) appendDiagnostics(`${spawnError}\n`);
|
|
366
398
|
resolve(1);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* C0 control characters except \t, \n, and \r, plus DEL. Same class the
|
|
3
|
+
* transcript renderer strips for display; also unsafe to quote into a
|
|
4
|
+
* subprocess argv entry (a NUL makes spawn throw before execve).
|
|
5
|
+
*/
|
|
6
|
+
const CONTROL_CHARACTERS = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g;
|
|
7
|
+
|
|
8
|
+
export function stripControlCharacters(text: string): string {
|
|
9
|
+
return text.replace(CONTROL_CHARACTERS, "");
|
|
10
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
import { stripControlCharacters } from "../shared/controlChars.ts";
|
|
2
3
|
import { wrapWords } from "./chrome.ts";
|
|
3
4
|
|
|
4
5
|
export const CONTENT_SEPARATOR = "\n\n---\n\n";
|
|
@@ -87,10 +88,11 @@ export function transcriptBody(
|
|
|
87
88
|
}
|
|
88
89
|
|
|
89
90
|
function stripTerminalControls(text: string): string {
|
|
90
|
-
return
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
return stripControlCharacters(
|
|
92
|
+
text
|
|
93
|
+
.replace(/\x1b\][^\x07]*(?:\x07|$)/g, "")
|
|
94
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""),
|
|
95
|
+
);
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
export function formatAgentPreview(
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionContext, KeybindingsManager, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import { Markdown, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Key, Markdown, matchesKey, type Component, type TUI } from "@earendil-works/pi-tui";
|
|
4
4
|
import { ROUNDED_SINGLE_BOX, createFrame, ratioViewport, safeRenderWidth } from "./chrome.ts";
|
|
5
5
|
|
|
6
6
|
const OVERLAY_HEIGHT_RATIO = 0.9;
|
|
@@ -78,6 +78,9 @@ class VerificationFindingsOverlay implements Component {
|
|
|
78
78
|
else if (keys.matches(data, "tui.select.pageUp")) delta = -(this.viewportLines() - 2);
|
|
79
79
|
else if (keys.matches(data, "tui.altScreen.top")) this.scrollOffset = 0;
|
|
80
80
|
else if (keys.matches(data, "tui.altScreen.bottom")) this.scrollOffset = Number.MAX_SAFE_INTEGER;
|
|
81
|
+
// Plain Home/End too, like the sibling views; pi 1.0.3 binds top/bottom to Ctrl+Home/Ctrl+End.
|
|
82
|
+
else if (matchesKey(data, Key.home)) this.scrollOffset = 0;
|
|
83
|
+
else if (matchesKey(data, Key.end)) this.scrollOffset = Number.MAX_SAFE_INTEGER;
|
|
81
84
|
else return;
|
|
82
85
|
|
|
83
86
|
this.scrollOffset += delta;
|