@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.2",
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.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",
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();
@@ -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
 
@@ -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
- const proc = spawn(invocation.command, invocation.args, {
206
- cwd: cwd ?? defaultCwd,
207
- shell: false,
208
- stdio: ["ignore", "pipe", "pipe"],
209
- // Read-only handshake for agentic provider bridges (see
210
- // READ_ONLY_SUBAGENT_ENV). The pi --tools allowlist above cannot
211
- // restrain a bridged agent's own tools.
212
- env: { ...process.env, ...READ_ONLY_SUBAGENT_ENV, ...USAGE_BEACON_SUBAGENT_ENV },
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 errorCode = "code" in error && typeof error.code === "string" ? error.code : undefined;
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 text
91
- .replace(/\x1b\][^\x07]*(?:\x07|$)/g, "")
92
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "")
93
- .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
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;