pi-task-dispatcher 3.0.2 → 3.1.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.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Each delegation runs in an isolated, in-memory Pi RPC session. After every cohes
10
10
  - **Enforced supervision:** a private parent-child IPC gate calls `ctx.abort()` after persisted tool results; pausing does not depend on the worker following a prompt.
11
11
  - **Explicit control:** continue, revise, recalculate, compact, checkpoint, cancel, or take over.
12
12
  - **Resumable context:** continuation uses the same in-memory worker session and completed tool results.
13
- - **Least privilege:** every start selects an exact isolated-worker tool allowlist; delegation tools and unrelated discovered extensions are unavailable to the worker. Pi's built-in `codemode` extension loads only when explicitly selected.
13
+ - **Least privilege:** every start selects an exact isolated-worker tool allowlist; delegation tools and unrelated discovered extensions are unavailable to the worker. When explicitly selected, a runtime-temporary extension loads Pi's public native `codemode` factory in `only` mode; it is never injected otherwise.
14
14
  - **Model agnostic:** every role inherits Pi's active model and thinking level unless an explicit role override is configured.
15
15
  - **Formula-owned budgets:** the manager supplies semantic workload units, while the dispatcher calculates turn and time ceilings from workload, role, and delegated-model limits.
16
16
  - **Context insight:** boundary reports include worker context usage, compactions, turns, elapsed time, action counts, and recommendations.
@@ -19,11 +19,11 @@ Each delegation runs in an isolated, in-memory Pi RPC session. After every cohes
19
19
 
20
20
  ## Requirements
21
21
 
22
- - Pi 1.0.4 or later
22
+ - Pi 1.1.0 or later
23
23
  - Node.js 22.19.0 or later
24
24
  - One active, authenticated Pi model; optional role-specific models must also be available and authenticated
25
25
 
26
- The supervision gate requires the parent Pi process and child Pi CLI to run under Node.js with an inherited IPC channel. The dispatcher fails closed when that boundary cannot be established; it does not fall back to prompt-only supervision. Worker processes disable ordinary extension discovery and load the generated supervision gate plus Pi's built-in `codemode` extension only when that tool was explicitly selected.
26
+ The supervision gate requires the parent Pi process and child Pi CLI to run under Node.js with an inherited IPC channel. The dispatcher fails closed when that boundary cannot be established; it does not fall back to prompt-only supervision. Worker processes disable ordinary extension discovery and load the generated supervision gate plus a generated native codemode-only extension only when that tool was explicitly selected.
27
27
 
28
28
  ## Installation
29
29
 
@@ -89,7 +89,7 @@ A workload unit is intentionally semantic:
89
89
 
90
90
  At least one investigation, change, or verification unit is required.
91
91
 
92
- Isolated workers accept `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`, and `codemode` when the selected tools are also active in the main Pi session. `codemode` is loaded as an explicit Pi built-in while ordinary extension discovery remains disabled. Its scripts can call only the worker's callable tools and non-LLM model catalog; unrelated manager extensions and delegation tools remain unavailable.
92
+ Isolated workers accept `read`, `bash`, `powershell`, `edit`, `write`, `grep`, `find`, `ls`, and `codemode` when the selected tools are also active in the main Pi session. If `codemode` is selected, the child generates a temporary extension using Pi's public `createCodemodeExtension({ mode: "only" })` API while ordinary extension discovery remains disabled. This makes `codemode` model-visible without exposing selected direct tools to the worker model; its scripts can still call those selected callable tools and the non-LLM model catalog. Unrelated manager extensions and delegation tools remain unavailable.
93
93
 
94
94
  The current formula derives planned work turns from those units, applies a role factor and a bounded scale based on the selected delegated model's own context window, reserves two additional turns for synthesis or recovery, then derives active execution time from calculated turns plus expected long-running time. Administrative ceilings always win. Formula inputs and results are returned in every state report so they can be reviewed and recalculated. Time spent paused for manager deliberation does not consume the active execution budget.
95
95
 
@@ -146,9 +146,10 @@ A paused result contains:
146
146
  - active elapsed time and turns against calculated budgets;
147
147
  - context tokens, context window, percentage, and compaction counts;
148
148
  - a recommended next decision;
149
- - bounded worker text, if any.
149
+ - bounded model-facing worker text, if any;
150
+ - a complete sanitized live presentation ledger in structured result details, including streamed assistant text and child-tool lifecycle/output placeholders without image base64.
150
151
 
151
- The TUI renders the same structured state as a compact block. Expanding the tool result reveals bounded worker text. The complete action ledger and worker text remain in result details for state reconstruction rather than being discarded by display truncation.
152
+ The TUI renders the same structured state as a compact block plus the latest five visual rows of the presentation ledger. When older rows are omitted, Pi's native tool-expansion key hint appears; expanding the tool result reveals the complete captured presentation text, not the 5,000-character model-facing worker-text preview. The complete action ledger, presentation ledger, and worker text remain in result details for state reconstruction rather than being discarded by display truncation.
152
153
 
153
154
  After the first worker state, one unindented, dim footer status remains below Pi's primary report and updates across worker lifecycle states. It retains the newest state observed for every invoked role, with the most recently updated role first and each entry ordered as identity, context, model, and thinking—for example, `engineer-2 · 10.3%/32k • model-name • medium | mechanic-1 · 8.1%/128k • smaller-model • low`. Each percentage and window comes from that delegation's own fresh worker session; a continuation accumulates context in the same job, while a new delegation starts a new context. Current or latest action details appear in the delegation or manager block rather than the footer.
154
155
 
@@ -208,7 +209,7 @@ Version 3 intentionally ignores the version 2 fields `maxTurns`, `timeoutSeconds
208
209
  - Session shutdown or extension reload disposes the child and runtime-controlled temporary files.
209
210
  - Worker process failures reject pending RPC operations and produce a failed state.
210
211
  - Worker role instructions and the supervision gate exist only in a runtime-controlled temporary directory.
211
- - The selected built-in `codemode` extension may create runtime-temporary image outputs; the designer must persist requested outputs explicitly before completion.
212
+ - The selected native codemode-only extension may create runtime-temporary image outputs; the designer must persist requested outputs explicitly before completion.
212
213
  - The worker uses `--no-session`; no worker session file is persisted.
213
214
 
214
215
  Process separation is not a security sandbox. Worker tools execute with the Pi host's operating-system permissions. Tool availability does not establish task scope or authorization.
@@ -220,7 +221,7 @@ npm run check
220
221
  npm run test:gate
221
222
  ```
222
223
 
223
- `check` runs static release invariants and deterministic fake-RPC lifecycle and renderer scenarios. The fake child verifies all role registrations, pause/resume, output-limit recovery, usage deltas, nested codemode usage accounting, media metadata without base64 retention, persistent per-role footer state, full action-ledger retention, intercepted prompts, missing-boundary failure, fail-closed timeouts, pre-aborted operations, partial argument rendering, stable repeated rendering, and manager-state transitions without a model request.
224
+ `check` runs static release invariants and deterministic fake-RPC lifecycle and renderer scenarios. The fake child verifies all role registrations, pause/resume, output-limit recovery, usage deltas, native codemode-only loading and nested direct-tool usage accounting, media metadata without base64 retention, streaming assistant/tool replacement semantics, five-visual-row previews with native expansion hints, complete expanded presentation beyond the 5,000-character model preview, persistent per-role footer state, full action-ledger retention, intercepted prompts, missing-boundary failure, fail-closed timeouts, pre-aborted operations, partial argument rendering, stable repeated rendering, and manager-state transitions without a model request.
224
225
 
225
- `test:gate` performs a small live model integration check with the active parent model exported by Pi through `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`, falling back to Pi's configured default when those values are unavailable; it contains no provider or model assumption. Optional `PI_TASK_DISPATCHER_GATE_MODEL` and `PI_TASK_DISPATCHER_GATE_THINKING` overrides are available for explicit test matrices. The probe verifies selective built-in `codemode` loading, a private pause after its completed tool batch, and subsequent completion in the same in-memory child. It requires configured credentials and therefore is not a hermetic unit test.
226
+ `test:gate` performs a small live model integration check with the active parent model exported by Pi through `PI_PROVIDER`, `PI_MODEL`, and `PI_REASONING_LEVEL`, falling back to Pi's configured default when those values are unavailable; it contains no provider or model assumption. Optional `PI_TASK_DISPATCHER_GATE_MODEL` and `PI_TASK_DISPATCHER_GATE_THINKING` overrides are available for explicit test matrices. The probe verifies generated native codemode-only loading, a codemode script calling an explicitly selected direct `read` tool, a private pause after that completed batch, and subsequent completion in the same in-memory child. It requires configured credentials and therefore is not a hermetic unit test.
226
227
 
@@ -10,8 +10,10 @@ import {
10
10
  type ExtensionAPI,
11
11
  type ExtensionToolContext,
12
12
  getAgentDir,
13
+ keyHint,
14
+ truncateToVisualLines,
13
15
  } from "@earendil-works/pi-coding-agent";
14
- import { Text } from "@earendil-works/pi-tui";
16
+ import { stripTerminalSequences, Text, truncateToWidth, type Component } from "@earendil-works/pi-tui";
15
17
  import { Type } from "typebox";
16
18
 
17
19
  type Role = "mechanic" | "assistant" | "engineer" | "designer";
@@ -99,6 +101,17 @@ interface WorkerAction {
99
101
  mediaOutputs?: WorkerMediaOutput[];
100
102
  }
101
103
 
104
+ /** Keep one replaceable presentation record for each assistant response or child tool lifecycle. */
105
+ interface WorkerPresentationEntry {
106
+ sequence: number;
107
+ id: string;
108
+ kind: "assistant" | "tool";
109
+ label: string;
110
+ status: "streaming" | "running" | "completed" | "failed";
111
+ text: string;
112
+ parentToolCallId?: string;
113
+ }
114
+
102
115
  interface ContextMetrics {
103
116
  tokens?: number;
104
117
  contextWindow: number;
@@ -135,6 +148,8 @@ interface WorkerSnapshot {
135
148
  recentActions: WorkerAction[];
136
149
  actionLedger: WorkerAction[];
137
150
  workerText: string;
151
+ /** Complete sanitized delegate activity retained only in structured tool details for UI expansion. */
152
+ presentationText: string;
138
153
  workerTextDisplay: string;
139
154
  workerTextTruncated: boolean;
140
155
  diagnostic?: string;
@@ -142,6 +157,21 @@ interface WorkerSnapshot {
142
157
  usage: UsageTotals;
143
158
  }
144
159
 
160
+ /** Retain only RPC content fields needed for text presentation and image metadata extraction. */
161
+ interface RpcContent {
162
+ type?: string;
163
+ text?: string;
164
+ data?: string;
165
+ mimeType?: string;
166
+ }
167
+
168
+ /** Model the result fields shared by partial and authoritative tool RPC events. */
169
+ interface RpcToolResult {
170
+ content?: RpcContent[];
171
+ usage?: EventUsage;
172
+ estimatedTokensAfter?: number;
173
+ }
174
+
145
175
  interface ParsedEvent {
146
176
  id?: string;
147
177
  type?: string;
@@ -154,14 +184,11 @@ interface ParsedEvent {
154
184
  aborted?: boolean;
155
185
  errorMessage?: string;
156
186
  willRetry?: boolean;
157
- result?: {
158
- content?: Array<{ type?: string; text?: string; data?: string; mimeType?: string }>;
159
- usage?: EventUsage;
160
- estimatedTokensAfter?: number;
161
- };
187
+ result?: RpcToolResult;
188
+ partialResult?: RpcToolResult;
162
189
  message?: {
163
190
  role?: string;
164
- content?: Array<{ type?: string; text?: string }>;
191
+ content?: RpcContent[];
165
192
  usage?: EventUsage;
166
193
  stopReason?: string;
167
194
  errorMessage?: string;
@@ -178,6 +205,7 @@ interface ParsedEvent {
178
205
  parentToolCallId?: string;
179
206
  args?: unknown;
180
207
  isError?: boolean;
208
+ durationMs?: number;
181
209
  }
182
210
 
183
211
  interface GateBoundaryMessage {
@@ -195,6 +223,8 @@ const CONFIG_PATH = TEST_MODE && process.env.PI_TASK_DISPATCHER_CONFIG_PATH
195
223
  const WORKER_ENV = "PI_TASK_DISPATCHER_WORKER";
196
224
  const WIDGET_ID = "pi-task-dispatcher-worker";
197
225
  const MODEL_TEXT_LIMIT = 5_000;
226
+ const PRESENTATION_PREVIEW_ROWS = 5;
227
+ const PRESENTATION_UPDATE_THROTTLE_MS = 100;
198
228
  const STDERR_LIMIT = 16_000;
199
229
  const THINKING_LEVELS = new Set<ThinkingLevel>(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
200
230
  const ROLES: Role[] = ["mechanic", "assistant", "engineer", "designer"];
@@ -345,10 +375,17 @@ function headTail(text: string, limit = MODEL_TEXT_LIMIT): { text: string; trunc
345
375
  };
346
376
  }
347
377
 
378
+ /** Remove terminal controls from child-provided text before it enters a structured snapshot or renderer. */
379
+ function sanitizePresentationText(value: unknown): string {
380
+ if (typeof value !== "string") return "";
381
+ return stripTerminalSequences(value)
382
+ .replace(/\r\n?/g, "\n")
383
+ .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, "");
384
+ }
385
+
348
386
  /** Keep one-line task and activity clues compact without introducing multiline omission markers. */
349
387
  function compactLine(value: unknown, limit = 180): string {
350
- if (typeof value !== "string") return "Preparing delegation…";
351
- const normalized = value.replaceAll(/\s+/g, " ").trim();
388
+ const normalized = sanitizePresentationText(value).replaceAll(/\s+/g, " ").trim();
352
389
  if (!normalized) return "Preparing delegation…";
353
390
  return normalized.length <= limit ? normalized : `${normalized.slice(0, Math.max(1, limit - 1))}…`;
354
391
  }
@@ -449,21 +486,53 @@ function getDirectPiInvocation(args: string[]): { command: string; args: string[
449
486
  /** Render a bounded activity label without embedding bulky command content. */
450
487
  function formatActivity(name: string, args: unknown): string {
451
488
  if (name === "bash" && isRecord(args) && typeof args.command === "string") {
452
- return `$ ${headTail(args.command, 140).text.replaceAll("\n", " ")}`;
489
+ return `$ ${headTail(sanitizePresentationText(args.command), 140).text.replaceAll("\n", " ")}`;
453
490
  }
454
- if (isRecord(args) && typeof args.path === "string") return `${name} ${args.path}`;
455
- return name;
491
+ if (isRecord(args) && typeof args.path === "string") return `${name} ${sanitizePresentationText(args.path)}`;
492
+ return sanitizePresentationText(name) || "tool";
456
493
  }
457
494
 
458
495
  /** Extract a text-only assistant report from one finalized RPC message. */
459
496
  function assistantText(event: ParsedEvent): string {
460
497
  return (event.message?.content ?? [])
461
- .filter((part) => part.type === "text" && part.text)
462
- .map((part) => part.text as string)
498
+ .filter((part) => part.type === "text" && typeof part.text === "string")
499
+ .map((part) => sanitizePresentationText(part.text))
500
+ .filter(Boolean)
463
501
  .join("\n")
464
502
  .trim();
465
503
  }
466
504
 
505
+ /** Extract text and image placeholders without retaining image base64 in state or rendered output. */
506
+ function presentationResult(result: RpcToolResult | undefined): { text: string; mediaOutputs: WorkerMediaOutput[] } {
507
+ const text: string[] = [];
508
+ const mediaOutputs: WorkerMediaOutput[] = [];
509
+ // Inspect content block types without persisting image payloads or redundant partial snapshots.
510
+ for (const part of result?.content ?? []) {
511
+ if (part.type === "text" && typeof part.text === "string") {
512
+ const value = sanitizePresentationText(part.text);
513
+ if (value) text.push(value);
514
+ continue;
515
+ }
516
+ if (part.type !== "image") continue;
517
+ const mimeType = sanitizePresentationText(part.mimeType) || "image";
518
+ const bytes = typeof part.data === "string" ? Buffer.from(part.data, "base64").length : 0;
519
+ if (typeof part.data === "string") mediaOutputs.push({ mimeType, bytes });
520
+ text.push(bytes > 0 ? `[image ${mimeType} · ${bytes} bytes]` : `[image ${mimeType}]`);
521
+ }
522
+ return { text: text.join("\n"), mediaOutputs };
523
+ }
524
+
525
+ /** Build the native codemode extension in only mode so selected direct tools stay script-callable but model-hidden. */
526
+ function codemodeOnlySource(): string {
527
+ return `/** Load Pi's public codemode factory without loading discovered extensions. */
528
+ import { createCodemodeExtension, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
529
+ const codemodeOnly = createCodemodeExtension({ mode: "only" });
530
+ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
531
+ codemodeOnly(pi);
532
+ }
533
+ `;
534
+ }
535
+
467
536
  /** Build the worker-only gate extension that stops the agent loop after persisted tool results. */
468
537
  function gateSource(): string {
469
538
  return `/** Pause this worker after each persisted tool batch so only the parent dispatcher can resume it. */
@@ -520,6 +589,11 @@ class WorkerJob {
520
589
  private requestSequence = 0;
521
590
  private actionSequence = 0;
522
591
  private completionSequence = 0;
592
+ private presentationSequence = 0;
593
+ private readonly presentationEntries: WorkerPresentationEntry[] = [];
594
+ private readonly presentationById = new Map<string, WorkerPresentationEntry>();
595
+ private readonly assistantTextBlocks = new Map<number, string>();
596
+ private activeAssistantPresentation: WorkerPresentationEntry | undefined;
523
597
  private activeActions = new Map<string, WorkerAction>();
524
598
  private pendingRequests = new Map<string, { resolve: (event: ParsedEvent) => void; reject: (error: Error) => void }>();
525
599
  private settleWaiter: { resolve: () => void; reject: (error: Error) => void } | undefined;
@@ -530,6 +604,7 @@ class WorkerJob {
530
604
  private activeElapsedMs = 0;
531
605
  private activeStartedAt: number | undefined;
532
606
  private progressCallback: ((snapshot: WorkerSnapshot) => void) | undefined;
607
+ private progressUpdate: ReturnType<typeof setTimeout> | undefined;
533
608
  private pausedLease: ReturnType<typeof setTimeout> | undefined;
534
609
  private closePromise: Promise<void> | undefined;
535
610
 
@@ -566,8 +641,12 @@ class WorkerJob {
566
641
  this.temporaryDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), "pi-task-dispatcher-"));
567
642
  const rolePromptPath = path.join(this.temporaryDirectory, `${this.role}.md`);
568
643
  const gatePath = path.join(this.temporaryDirectory, "supervision-gate.ts");
644
+ const codemodeOnlyPath = path.join(this.temporaryDirectory, "codemode-only.ts");
569
645
  await fs.promises.writeFile(rolePromptPath, ROLE_PROMPTS[this.role], { encoding: "utf8", mode: 0o600 });
570
646
  await fs.promises.writeFile(gatePath, gateSource(), { encoding: "utf8", mode: 0o600 });
647
+ if (this.tools.includes("codemode")) {
648
+ await fs.promises.writeFile(codemodeOnlyPath, codemodeOnlySource(), { encoding: "utf8", mode: 0o600 });
649
+ }
571
650
 
572
651
  const args = [
573
652
  "--mode", "rpc",
@@ -577,7 +656,7 @@ class WorkerJob {
577
656
  "--thinking", this.thinking,
578
657
  ...(this.tools.length > 0 ? ["--tools", this.tools.join(",")] : ["--no-tools"]),
579
658
  "--append-system-prompt", rolePromptPath,
580
- ...(this.tools.includes("codemode") ? ["--extension", "builtin:codemode"] : []),
659
+ ...(this.tools.includes("codemode") ? ["--extension", codemodeOnlyPath] : []),
581
660
  "--extension", gatePath,
582
661
  ];
583
662
  const invocation = getDirectPiInvocation(args);
@@ -681,6 +760,7 @@ class WorkerJob {
681
760
  /** Return a stable state projection without exposing process handles or raw RPC records. */
682
761
  snapshot(): WorkerSnapshot {
683
762
  const display = headTail(this.workerText);
763
+ const presentationText = this.presentationText();
684
764
  const completed = this.actions.filter((action) => action.status === "completed").length;
685
765
  const failed = this.actions.filter((action) => action.status === "failed").length;
686
766
  const currentAction = [...this.actions].reverse().find((action) => action.status === "running");
@@ -714,6 +794,7 @@ class WorkerJob {
714
794
  recentActions,
715
795
  actionLedger: this.actions.map((action) => ({ ...action, mediaOutputs: action.mediaOutputs?.map((output) => ({ ...output })) })),
716
796
  workerText: this.workerText,
797
+ presentationText,
717
798
  workerTextDisplay: display.text,
718
799
  workerTextTruncated: display.truncated,
719
800
  diagnostic: this.diagnostic,
@@ -726,6 +807,8 @@ class WorkerJob {
726
807
  async dispose(): Promise<void> {
727
808
  if (this.closePromise) return await this.closePromise;
728
809
  this.clearPausedLease();
810
+ if (this.progressUpdate) clearTimeout(this.progressUpdate);
811
+ this.progressUpdate = undefined;
729
812
  this.closePromise = (async () => {
730
813
  const child = this.child;
731
814
  if (child && child.exitCode === null && child.signalCode === null) {
@@ -789,7 +872,7 @@ class WorkerJob {
789
872
  });
790
873
  }
791
874
 
792
- /** Reduce one complete JSONL record into requests, activity, usage, and settlement state. */
875
+ /** Reduce one complete JSONL record into requests, activity, usage, presentation, and settlement state. */
793
876
  private processLine(line: string): void {
794
877
  if (!line.trim()) return;
795
878
  let event: ParsedEvent;
@@ -806,7 +889,20 @@ class WorkerJob {
806
889
  }
807
890
  return;
808
891
  }
892
+ if (event.type === "message_start" && event.message?.role === "assistant") {
893
+ this.beginAssistantPresentation();
894
+ this.emitProgress();
895
+ return;
896
+ }
897
+ if (event.type === "message_update" && event.assistantMessageEvent) {
898
+ this.updateAssistantPresentation(event.assistantMessageEvent);
899
+ this.scheduleProgressUpdate();
900
+ return;
901
+ }
809
902
  if (event.type === "message_end" && event.message?.role === "assistant") {
903
+ const text = assistantText(event);
904
+ this.completeAssistantPresentation(text);
905
+ if (text) this.workerText = text;
810
906
  const stopReason = event.message.stopReason;
811
907
  const gateAbort =
812
908
  (stopReason === "aborted" || stopReason === "error") &&
@@ -815,10 +911,9 @@ class WorkerJob {
815
911
  this.turns++;
816
912
  this.lastStopReason = stopReason;
817
913
  addUsage(this.usage, event.message.usage ?? event.usage);
818
- const text = assistantText(event);
819
- if (text) this.workerText = text;
820
- if (event.message.errorMessage) this.diagnostic = event.message.errorMessage;
914
+ if (event.message.errorMessage) this.diagnostic = sanitizePresentationText(event.message.errorMessage);
821
915
  }
916
+ this.emitProgress();
822
917
  return;
823
918
  }
824
919
  if (event.type === "tool_execution_start" && event.toolCallId && event.toolName) {
@@ -834,23 +929,36 @@ class WorkerJob {
834
929
  };
835
930
  this.actions.push(action);
836
931
  this.activeActions.set(action.toolCallId, action);
932
+ this.ensureToolPresentation(event.toolCallId, event.toolName, event.args, event.parentToolCallId);
837
933
  this.emitProgress();
838
934
  return;
839
935
  }
936
+ if (event.type === "tool_execution_update" && event.toolCallId && event.toolName) {
937
+ const entry = this.ensureToolPresentation(event.toolCallId, event.toolName, event.args, event.parentToolCallId);
938
+ entry.status = "running";
939
+ entry.text = presentationResult(event.partialResult).text;
940
+ this.scheduleProgressUpdate();
941
+ return;
942
+ }
840
943
  if (event.type === "tool_execution_end" && event.toolCallId) {
944
+ const result = presentationResult(event.result);
841
945
  const action = this.activeActions.get(event.toolCallId);
946
+ const entry = this.ensureToolPresentation(
947
+ event.toolCallId,
948
+ event.toolName ?? action?.toolName ?? "tool",
949
+ event.args,
950
+ event.parentToolCallId ?? action?.parentToolCallId,
951
+ );
952
+ entry.status = event.isError ? "failed" : "completed";
953
+ entry.text = result.text;
842
954
  if (action) {
843
955
  action.status = event.isError ? "failed" : "completed";
844
956
  action.endedAt = Date.now();
845
957
  action.completionSequence = ++this.completionSequence;
846
- const mediaOutputs = (event.result?.content ?? []).flatMap((part) =>
847
- part.type === "image" && typeof part.mimeType === "string" && typeof part.data === "string"
848
- ? [{ mimeType: part.mimeType, bytes: Buffer.from(part.data, "base64").length }]
849
- : []
850
- );
851
- if (mediaOutputs.length > 0) {
852
- action.mediaOutputs = mediaOutputs;
853
- action.label += ` · ${mediaOutputs.length} image${mediaOutputs.length === 1 ? "" : "s"}`;
958
+ if (result.mediaOutputs.length > 0) {
959
+ action.mediaOutputs = result.mediaOutputs;
960
+ action.label += ` · ${result.mediaOutputs.length} image${result.mediaOutputs.length === 1 ? "" : "s"}`;
961
+ entry.label = action.label;
854
962
  }
855
963
  this.activeActions.delete(event.toolCallId);
856
964
  }
@@ -873,6 +981,96 @@ class WorkerJob {
873
981
  if (event.type === "agent_settled") this.settleWaiter?.resolve();
874
982
  }
875
983
 
984
+ /** Start a replaceable assistant entry so streamed deltas never duplicate retained presentation text. */
985
+ private beginAssistantPresentation(): WorkerPresentationEntry {
986
+ if (this.activeAssistantPresentation) return this.activeAssistantPresentation;
987
+ this.assistantTextBlocks.clear();
988
+ const entry: WorkerPresentationEntry = {
989
+ sequence: ++this.presentationSequence,
990
+ id: `assistant-${this.presentationSequence}`,
991
+ kind: "assistant",
992
+ label: "assistant",
993
+ status: "streaming",
994
+ text: "",
995
+ };
996
+ this.presentationEntries.push(entry);
997
+ this.presentationById.set(entry.id, entry);
998
+ this.activeAssistantPresentation = entry;
999
+ return entry;
1000
+ }
1001
+
1002
+ /** Apply JSON/RPC assistant text deltas and completed-block replacements by content index. */
1003
+ private updateAssistantPresentation(update: NonNullable<ParsedEvent["assistantMessageEvent"]>): void {
1004
+ if (!new Set(["text_start", "text_delta", "text_end"]).has(update.type ?? "")) return;
1005
+ const entry = this.beginAssistantPresentation();
1006
+ const index = typeof update.contentIndex === "number" ? update.contentIndex : 0;
1007
+ if (update.type === "text_start") this.assistantTextBlocks.set(index, "");
1008
+ else if (update.type === "text_delta") {
1009
+ this.assistantTextBlocks.set(index, `${this.assistantTextBlocks.get(index) ?? ""}${sanitizePresentationText(update.delta)}`);
1010
+ } else {
1011
+ // JSON/RPC text_end content is authoritative for this streamed block.
1012
+ this.assistantTextBlocks.set(index, sanitizePresentationText(update.content));
1013
+ }
1014
+ entry.text = [...this.assistantTextBlocks.entries()]
1015
+ .sort(([left], [right]) => left - right)
1016
+ .map(([, value]) => value)
1017
+ .filter(Boolean)
1018
+ .join("\n")
1019
+ .trim();
1020
+ }
1021
+
1022
+ /** Replace the live assistant entry with the authoritative final assistant message. */
1023
+ private completeAssistantPresentation(text: string): void {
1024
+ const entry = this.activeAssistantPresentation ?? (text ? this.beginAssistantPresentation() : undefined);
1025
+ if (!entry) return;
1026
+ entry.text = text;
1027
+ entry.status = "completed";
1028
+ this.assistantTextBlocks.clear();
1029
+ this.activeAssistantPresentation = undefined;
1030
+ }
1031
+
1032
+ /** Reuse one ordered presentation record per tool call while preserving child-call relationships. */
1033
+ private ensureToolPresentation(toolCallId: string, toolName: string, args: unknown, parentToolCallId?: string): WorkerPresentationEntry {
1034
+ const id = `tool-${toolCallId}`;
1035
+ const existing = this.presentationById.get(id);
1036
+ if (existing) return existing;
1037
+ const action = this.activeActions.get(toolCallId);
1038
+ const entry: WorkerPresentationEntry = {
1039
+ sequence: ++this.presentationSequence,
1040
+ id,
1041
+ kind: "tool",
1042
+ label: action?.label ?? formatActivity(toolName, args),
1043
+ status: "running",
1044
+ text: "",
1045
+ parentToolCallId,
1046
+ };
1047
+ this.presentationEntries.push(entry);
1048
+ this.presentationById.set(id, entry);
1049
+ return entry;
1050
+ }
1051
+
1052
+ /** Format retained presentation entries from their latest state without retaining repeated partial snapshots. */
1053
+ private presentationText(): string {
1054
+ return this.presentationEntries
1055
+ .map((entry) => {
1056
+ if (entry.kind === "assistant") {
1057
+ if (!entry.text) return "";
1058
+ return `${entry.status === "streaming" ? "assistant streaming" : "assistant"}:\n${entry.text}`;
1059
+ }
1060
+ const kind = entry.parentToolCallId ? "child tool" : "tool";
1061
+ const lines = [`${kind} start: ${entry.label}`];
1062
+ if (entry.status === "running") {
1063
+ if (entry.text) lines.push(`${kind} update: ${entry.label}`, entry.text);
1064
+ } else {
1065
+ lines.push(`${kind} ${entry.status}: ${entry.label}`);
1066
+ if (entry.text) lines.push(entry.text);
1067
+ }
1068
+ return lines.join("\n");
1069
+ })
1070
+ .filter(Boolean)
1071
+ .join("\n\n");
1072
+ }
1073
+
876
1074
  /** Execute one or more supervised action batches while respecting calculated ceilings. */
877
1075
  private async runStages(
878
1076
  initialMessage: string,
@@ -1082,8 +1280,19 @@ class WorkerJob {
1082
1280
  if (directory) await fs.promises.rm(directory, { recursive: true, force: true });
1083
1281
  }
1084
1282
 
1283
+ /** Coalesce token and partial-result bursts before rebuilding and serializing the complete snapshot. */
1284
+ private scheduleProgressUpdate(): void {
1285
+ if (this.progressUpdate) return;
1286
+ this.progressUpdate = setTimeout(() => {
1287
+ this.progressUpdate = undefined;
1288
+ this.emitProgress();
1289
+ }, PRESENTATION_UPDATE_THROTTLE_MS);
1290
+ }
1291
+
1085
1292
  /** Refresh the partial tool block with elapsed time and the latest active command. */
1086
1293
  private emitProgress(): void {
1294
+ if (this.progressUpdate) clearTimeout(this.progressUpdate);
1295
+ this.progressUpdate = undefined;
1087
1296
  try {
1088
1297
  this.progressCallback?.(this.snapshot());
1089
1298
  } catch {
@@ -1172,6 +1381,15 @@ function formatElapsed(durationMs: number): string {
1172
1381
  return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
1173
1382
  }
1174
1383
 
1384
+ /** Use Pi's configured expansion binding when interactive UI state exists, with a harmless noninteractive render fallback. */
1385
+ function expansionHint(theme: any): string {
1386
+ try {
1387
+ return keyHint("app.tools.expand", "to expand");
1388
+ } catch {
1389
+ return theme.fg("muted", "to expand");
1390
+ }
1391
+ }
1392
+
1175
1393
  /** Format one bounded state report for both model consumption and non-TUI modes. */
1176
1394
  function formatSnapshot(snapshot: WorkerSnapshot): string {
1177
1395
  const context = snapshot.context.percent === undefined
@@ -1233,29 +1451,144 @@ function renderArgumentSummary(value: unknown): { task: string; workload: string
1233
1451
  return { task: compactLine(value.task), workload: `${tools} tools · ${investigation}/${change}/${verification} I/C/V units` };
1234
1452
  }
1235
1453
 
1236
- /** Create a wrapping component whose text can reflect shared renderer state after execution updates. */
1237
- function dynamicText(build: () => string): { render(width: number): string[]; invalidate(): void } {
1238
- const text = new Text("", 0, 0);
1239
- let currentText: string | undefined;
1240
- return {
1241
- render(width) {
1242
- let nextText: string;
1243
- try {
1244
- nextText = build();
1245
- } catch {
1246
- nextText = "Preparing delegation…";
1247
- }
1248
- // Text.setText() invalidates the TUI, so call it only when observable content changed.
1249
- if (nextText !== currentText) {
1250
- currentText = nextText;
1251
- text.setText(nextText);
1454
+ /** Reuse dynamic text components while changing their source only outside the TUI render cycle. */
1455
+ class DynamicTextComponent {
1456
+ private readonly text = new Text("", 0, 0);
1457
+ private currentText: string | undefined;
1458
+ private build: () => string;
1459
+
1460
+ constructor(build: () => string) {
1461
+ this.build = build;
1462
+ }
1463
+
1464
+ setBuild(build: () => string): void {
1465
+ this.build = build;
1466
+ }
1467
+
1468
+ render(width: number): string[] {
1469
+ let nextText: string;
1470
+ try {
1471
+ nextText = this.build();
1472
+ } catch {
1473
+ nextText = "Preparing delegation…";
1474
+ }
1475
+ // Text.setText() invalidates the TUI, so call it only when observable content changed.
1476
+ if (nextText !== this.currentText) {
1477
+ this.currentText = nextText;
1478
+ this.text.setText(nextText);
1479
+ }
1480
+ return this.text.render(width);
1481
+ }
1482
+
1483
+ invalidate(): void {
1484
+ this.text.invalidate();
1485
+ }
1486
+ }
1487
+
1488
+ /** Reuse the call-renderer component from Pi's context instead of allocating one for every update. */
1489
+ function dynamicText(context: any, build: () => string, stateKey = "callComponent"): Component {
1490
+ const state = rendererState(context);
1491
+ const previous = context.lastComponent;
1492
+ const component = previous instanceof DynamicTextComponent
1493
+ ? previous
1494
+ : state[stateKey] instanceof DynamicTextComponent
1495
+ ? state[stateKey] as DynamicTextComponent
1496
+ : new DynamicTextComponent(build);
1497
+ component.setBuild(build);
1498
+ state[stateKey] = component;
1499
+ return component;
1500
+ }
1501
+
1502
+ /** Render the full presentation ledger with Pi's native visual-row truncation and expansion hint. */
1503
+ class DelegateResultComponent {
1504
+ private snapshot: WorkerSnapshot | undefined;
1505
+ private expanded = false;
1506
+ private isPartial = false;
1507
+ private standalone = false;
1508
+ private theme: any = undefined;
1509
+ private signature = "";
1510
+ private cachedWidth: number | undefined;
1511
+ private cachedLines: string[] | undefined;
1512
+
1513
+ update(snapshot: WorkerSnapshot, expanded: boolean, isPartial: boolean, theme: any, standalone: boolean): void {
1514
+ const presentation = snapshot.presentationText ?? "";
1515
+ const signature = [
1516
+ snapshot.jobId,
1517
+ snapshot.status,
1518
+ snapshot.taskDisplay,
1519
+ snapshot.phase,
1520
+ snapshot.sequence,
1521
+ snapshot.turns,
1522
+ snapshot.durationMs,
1523
+ snapshot.currentActivity ?? "",
1524
+ snapshot.actionCounts.completed,
1525
+ snapshot.actionCounts.failed,
1526
+ snapshot.recommendation,
1527
+ presentation,
1528
+ expanded,
1529
+ isPartial,
1530
+ standalone,
1531
+ ].join("\u0000");
1532
+ if (this.signature === signature && this.theme === theme) return;
1533
+ this.snapshot = snapshot;
1534
+ this.expanded = expanded;
1535
+ this.isPartial = isPartial;
1536
+ this.standalone = standalone;
1537
+ this.theme = theme;
1538
+ this.signature = signature;
1539
+ this.cachedWidth = undefined;
1540
+ this.cachedLines = undefined;
1541
+ }
1542
+
1543
+ render(width: number): string[] {
1544
+ if (this.cachedWidth === width && this.cachedLines) return this.cachedLines;
1545
+ const snapshot = this.snapshot;
1546
+ if (!snapshot) return [];
1547
+ const color = snapshot.status === "paused" ? "warning" : snapshot.status === "completed" ? "success" : isTerminal(snapshot.status) && snapshot.status !== "completed" ? "error" : "accent";
1548
+ const latestActivity = snapshot.currentActivity ?? snapshot.recentActions.at(-1)?.label;
1549
+ const activityLabel = snapshot.currentActivity ? "Now" : "Last";
1550
+ const header = [
1551
+ this.standalone ? this.theme.fg(color, this.theme.bold(`${snapshot.jobId}: ${snapshot.status}`)) : "",
1552
+ this.standalone ? this.theme.fg("muted", `Task: ${snapshot.taskDisplay}`) : "",
1553
+ latestActivity ? this.theme.fg(snapshot.currentActivity ? "warning" : "muted", `${activityLabel}: ${latestActivity}`) : "",
1554
+ this.theme.fg("dim", `${snapshot.phase} · boundary ${snapshot.sequence} · turn ${snapshot.turns}/${snapshot.budget.turns}`),
1555
+ this.theme.fg("muted", `${snapshot.actionCounts.completed} actions complete · ${snapshot.actionCounts.failed} failed · ${formatContext(snapshot)}`),
1556
+ this.theme.fg("accent", snapshot.recommendation),
1557
+ ].filter(Boolean).join("\n");
1558
+ const lines = new Text(header, 0, 0).render(width);
1559
+ const presentation = snapshot.presentationText ?? "";
1560
+ if (presentation) {
1561
+ // Reapply styling per line because Pi resets terminal attributes at every rendered row.
1562
+ const styledPresentation = presentation
1563
+ .split("\n")
1564
+ .map((line) => this.theme.fg("dim", line))
1565
+ .join("\n");
1566
+ if (this.expanded) {
1567
+ lines.push("", ...new Text(styledPresentation, 0, 0).render(width));
1568
+ } else {
1569
+ const preview = truncateToVisualLines(styledPresentation, PRESENTATION_PREVIEW_ROWS, width, 0, "end");
1570
+ if (preview.visualLines.length > 0) {
1571
+ lines.push("");
1572
+ if (preview.skippedCount > 0) {
1573
+ const hint = this.theme.fg("muted", `… ${preview.skippedCount} earlier visual rows omitted (`)
1574
+ + expansionHint(this.theme)
1575
+ + this.theme.fg("muted", ")");
1576
+ lines.push(truncateToWidth(hint, width, "..."));
1577
+ }
1578
+ lines.push(...preview.visualLines);
1579
+ }
1252
1580
  }
1253
- return text.render(width);
1254
- },
1255
- invalidate() {
1256
- text.invalidate();
1257
- },
1258
- };
1581
+ }
1582
+ lines.push(this.theme.fg("dim", `${this.isPartial ? "Elapsed" : "Took"}: ${formatElapsed(snapshot.durationMs)}`));
1583
+ this.cachedWidth = width;
1584
+ this.cachedLines = lines;
1585
+ return lines;
1586
+ }
1587
+
1588
+ invalidate(): void {
1589
+ this.cachedWidth = undefined;
1590
+ this.cachedLines = undefined;
1591
+ }
1259
1592
  }
1260
1593
 
1261
1594
  /** Render only nonredundant result details; the shared call header owns identity, task, and status. */
@@ -1266,25 +1599,20 @@ function renderWorkerResult(
1266
1599
  theme: any,
1267
1600
  context: any,
1268
1601
  standalone = false,
1269
- ): Text {
1602
+ ): Component {
1270
1603
  const snapshot = result.details as WorkerSnapshot | undefined;
1271
- if (!snapshot) return new Text(theme.fg(isPartial ? "warning" : "muted", result.content[0]?.text ?? "Delegation state unavailable"), 0, 0);
1272
- rendererState(context).snapshot = snapshot;
1273
- const color = snapshot.status === "paused" ? "warning" : snapshot.status === "completed" ? "success" : isTerminal(snapshot.status) && snapshot.status !== "completed" ? "error" : "accent";
1274
- const elapsedLabel = isPartial ? "Elapsed" : "Took";
1275
- const latestActivity = snapshot.currentActivity ?? snapshot.recentActions.at(-1)?.label;
1276
- const activityLabel = snapshot.currentActivity ? "Now" : "Last";
1277
- const lines = [
1278
- standalone ? theme.fg(color, theme.bold(`${snapshot.jobId}: ${snapshot.status}`)) : "",
1279
- standalone ? theme.fg("muted", `Task: ${snapshot.taskDisplay}`) : "",
1280
- latestActivity ? theme.fg(snapshot.currentActivity ? "warning" : "muted", `${activityLabel}: ${latestActivity}`) : "",
1281
- theme.fg("dim", `${snapshot.phase} · boundary ${snapshot.sequence} · turn ${snapshot.turns}/${snapshot.budget.turns}`),
1282
- theme.fg("muted", `${snapshot.actionCounts.completed} actions complete · ${snapshot.actionCounts.failed} failed · ${formatContext(snapshot)}`),
1283
- theme.fg("accent", snapshot.recommendation),
1284
- expanded && snapshot.workerTextDisplay ? `\n${snapshot.workerTextDisplay}` : "",
1285
- theme.fg("dim", `${elapsedLabel}: ${formatElapsed(snapshot.durationMs)}`),
1286
- ].filter(Boolean);
1287
- return new Text(lines.join("\n"), 0, 0);
1604
+ if (!snapshot) return dynamicText(context, () => theme.fg(isPartial ? "warning" : "muted", result.content[0]?.text ?? "Delegation state unavailable"), "resultTextComponent");
1605
+ const state = rendererState(context);
1606
+ state.snapshot = snapshot;
1607
+ const previous = context.lastComponent;
1608
+ const component = previous instanceof DelegateResultComponent
1609
+ ? previous
1610
+ : state.resultComponent instanceof DelegateResultComponent
1611
+ ? state.resultComponent as DelegateResultComponent
1612
+ : new DelegateResultComponent();
1613
+ component.update(snapshot, expanded, isPartial, theme, standalone);
1614
+ state.resultComponent = component;
1615
+ return component;
1288
1616
  }
1289
1617
 
1290
1618
  /** Package a snapshot as bounded model content plus complete machine-readable details. */
@@ -1326,7 +1654,7 @@ function registerDelegate(pi: ExtensionAPI, role: Role): void {
1326
1654
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1327
1655
  renderCall(args, theme, context) {
1328
1656
  const expectedId = `${role}-${nextJobIndex}`;
1329
- return dynamicText(() => {
1657
+ return dynamicText(context, () => {
1330
1658
  const snapshot = rendererState(context).snapshot as WorkerSnapshot | undefined;
1331
1659
  const current = renderArgumentSummary(context.args ?? args);
1332
1660
  const id = snapshot?.jobId ?? expectedId;
@@ -1388,7 +1716,7 @@ function registerControl(pi: ExtensionAPI): void {
1388
1716
  executionMode: "sequential",
1389
1717
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
1390
1718
  renderCall(args, theme, context) {
1391
- return dynamicText(() => {
1719
+ return dynamicText(context, () => {
1392
1720
  const snapshot = rendererState(context).snapshot as WorkerSnapshot | undefined;
1393
1721
  const current = isRecord(context.args) ? context.args : isRecord(args) ? args : {};
1394
1722
  const jobId = typeof current.jobId === "string" ? current.jobId : "delegation";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-task-dispatcher",
3
- "version": "3.0.2",
3
+ "version": "3.1.0",
4
4
  "description": "Run configurable Pi worker models as supervised, observable, resumable jobs.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -94,7 +94,10 @@ try {
94
94
  assert.match(partialRender, /Elapsed:/);
95
95
  assert.equal(statusUpdates.at(-1)?.text, "engineer-1 · 11.0%/1k • active-model • medium");
96
96
  const resultComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext);
97
- const firstRender = resultComponent.render(100).join("\n");
97
+ renderContext.lastComponent = resultComponent;
98
+ const reusedResultComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, renderContext);
99
+ assert.equal(reusedResultComponent, resultComponent, "The result renderer did not reuse Pi's last component.");
100
+ const firstRender = reusedResultComponent.render(100).join("\n");
98
101
  assert.match(firstRender, /Took:/);
99
102
  assert.doesNotMatch(firstRender, /Elapsed:/);
100
103
  for (let index = 0; index < 1_000; index++) {
@@ -124,7 +127,7 @@ try {
124
127
  assert.match(managerCall.render(100).join("\n"), /manager: engineer-1 → completed/);
125
128
  assert.doesNotMatch(managerCall.render(100).join("\n"), /continue\/completed/);
126
129
 
127
- // The designer must load only the selected built-in codemode extension and account nested usage once.
130
+ // The designer must load only the selected native codemode-only extension and account nested usage once.
128
131
  await writeFile(configPath, JSON.stringify({ designer: { model: "media-provider/media-model", thinking: "high" } }), "utf8");
129
132
  process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "codemode";
130
133
  result = await designerStart.execute("start-designer", { task: "fake image job", tools: ["read", "codemode"], workload }, undefined, undefined, context);
@@ -158,6 +161,57 @@ try {
158
161
  assert.match(statusUpdates.at(-1)?.text ?? "", /designer-2.*\|.*engineer-1/);
159
162
  await writeFile(configPath, "{}\n", "utf8");
160
163
 
164
+ // Streaming RPC updates must replace their partial text, preserve full UI details, and keep model output bounded.
165
+ process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "presentation";
166
+ const presentationUpdates = [];
167
+ result = await start.execute(
168
+ "start-presentation",
169
+ { task: "fake presentation job", tools: ["read"], workload },
170
+ undefined,
171
+ (update) => presentationUpdates.push(update.details?.presentationText ?? ""),
172
+ context,
173
+ );
174
+ assert(presentationUpdates.some((text) => text.includes("stream delta that must be replaced")), "Live assistant stream text was not presented.");
175
+ assert(presentationUpdates.some((text) => text.includes("partial read result that must be replaced")), "Live tool partial text was not presented.");
176
+ assert.equal(result.isError, false);
177
+ assert.equal(result.details.status, "paused");
178
+ assert.match(result.details.presentationText, /authoritative first assistant message/);
179
+ assert.match(result.details.presentationText, /tool start: read presentation\.txt/);
180
+ assert.match(result.details.presentationText, /tool completed: read presentation\.txt/);
181
+ assert.match(result.details.presentationText, /final read result six red/);
182
+ assert.doesNotMatch(result.details.presentationText, /stream delta that must be replaced|stream block that must be replaced|partial read result that must be replaced/);
183
+ assert.doesNotMatch(result.details.presentationText, /\u001b/, "Presentation text must not retain terminal control sequences.");
184
+ const presentationRenderContext = { args: {}, state: {}, invalidate() { rendererInvalidations++; }, cwd: projectRoot };
185
+ const collapsedComponent = start.renderResult(result, { expanded: false, isPartial: false }, theme, presentationRenderContext);
186
+ presentationRenderContext.lastComponent = collapsedComponent;
187
+ const collapsed = collapsedComponent.render(100).join("\n");
188
+ assert.match(collapsed, /earlier visual rows omitted/);
189
+ assert.match(collapsed, /to expand/);
190
+ assert.match(collapsed, /final read result six red/);
191
+ assert.doesNotMatch(collapsed, /authoritative first assistant message/, "Collapsed output must retain only the latest visual rows.");
192
+
193
+ result = await control.execute(
194
+ "continue-presentation",
195
+ { jobId: result.details.jobId, action: "continue", actionBatches: 1 },
196
+ undefined,
197
+ undefined,
198
+ context,
199
+ );
200
+ assert.equal(result.isError, false);
201
+ assert.equal(result.details.status, "completed");
202
+ assert(result.details.workerTextDisplay.length <= 5_000, "Model-facing worker text was not bounded.");
203
+ assert(result.details.presentationText.length > 5_000, "Structured presentation text was unexpectedly bounded.");
204
+ assert.match(result.details.presentationText, /MIDDLE-ONLY-IN-EXPANDED-PRESENTATION/);
205
+ assert.doesNotMatch(result.content[0].text, /MIDDLE-ONLY-IN-EXPANDED-PRESENTATION/, "Model-facing state exposed the omitted middle of a long report.");
206
+ const expandedComponent = control.renderResult(result, { expanded: true, isPartial: false }, theme, presentationRenderContext);
207
+ presentationRenderContext.lastComponent = expandedComponent;
208
+ const expanded = expandedComponent.render(100).join("\n");
209
+ assert.match(expanded, /MIDDLE-ONLY-IN-EXPANDED-PRESENTATION/);
210
+ assert.match(expanded, /latest-presentation-marker/);
211
+ const reusedExpandedComponent = control.renderResult(result, { expanded: true, isPartial: false }, theme, presentationRenderContext);
212
+ assert.equal(reusedExpandedComponent, expandedComponent, "Expanded renderer state was not reused.");
213
+ assert.equal(reusedExpandedComponent.render(100).join("\n"), expanded, "Repeated expanded rendering changed output.");
214
+
161
215
  // A tool-free output-limit stop must preserve the session so the manager can recover the report.
162
216
  process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO = "length";
163
217
  result = await start.execute("start-length", { task: "fake truncated job", tools: ["read"], workload }, undefined, undefined, context);
@@ -1,6 +1,6 @@
1
1
  /** Deterministic JSONL/IPC worker used only by the dispatcher's lifecycle tests. */
2
2
 
3
- import { appendFile } from "node:fs/promises";
3
+ import { appendFile, readFile } from "node:fs/promises";
4
4
  import readline from "node:readline";
5
5
 
6
6
  const scenario = process.env.PI_TASK_DISPATCHER_FAKE_SCENARIO ?? "normal";
@@ -54,17 +54,91 @@ readline.createInterface({ input: process.stdin }).on("line", async (line) => {
54
54
  return;
55
55
  }
56
56
 
57
- if (scenario === "codemode" && promptCount === 1) {
57
+ if (promptCount === 1) {
58
58
  const toolsIndex = process.argv.indexOf("--tools");
59
59
  const selectedTools = toolsIndex >= 0 ? process.argv[toolsIndex + 1]?.split(",") ?? [] : [];
60
60
  const extensionValues = process.argv.flatMap((argument, index) => argument === "--extension" ? [process.argv[index + 1]] : []);
61
- if (!selectedTools.includes("codemode") || !extensionValues.includes("builtin:codemode")) {
62
- send({ type: "response", id: command.id, command: command.type, success: false, error: "codemode was not isolated and loaded" });
61
+ if (scenario === "normal" && extensionValues.some((value) => value?.endsWith("codemode-only.ts"))) {
62
+ send({ type: "response", id: command.id, command: command.type, success: false, error: "codemode extension was injected without explicit selection" });
63
63
  return;
64
64
  }
65
+ if (scenario === "codemode") {
66
+ const nativeCodemodePath = extensionValues.find((value) => value?.endsWith("codemode-only.ts"));
67
+ let nativeCodemodeSource = "";
68
+ try {
69
+ if (nativeCodemodePath) nativeCodemodeSource = await readFile(nativeCodemodePath, "utf8");
70
+ } catch {
71
+ // The failed source check below reports a deterministic fake-RPC error.
72
+ }
73
+ if (
74
+ !selectedTools.includes("codemode")
75
+ || extensionValues.includes("builtin:codemode")
76
+ || !nativeCodemodePath
77
+ || !nativeCodemodeSource.includes('createCodemodeExtension({ mode: "only" })')
78
+ || !nativeCodemodeSource.includes('from "@earendil-works/pi-coding-agent"')
79
+ ) {
80
+ send({ type: "response", id: command.id, command: command.type, success: false, error: "native codemode-only extension was not isolated and loaded" });
81
+ return;
82
+ }
83
+ }
65
84
  }
66
85
 
67
86
  send({ type: "response", id: command.id, command: command.type, success: true, data: { disposition: "started" } });
87
+ if (scenario === "presentation") {
88
+ if (promptCount === 1) {
89
+ send({ type: "message_start", message: { role: "assistant" } });
90
+ send({ type: "message_update", assistantMessageEvent: { type: "text_start", contentIndex: 0 } });
91
+ send({ type: "message_update", assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "stream delta that must be replaced" } });
92
+ // Keep the fake stream active long enough to exercise the dispatcher's coalesced live update.
93
+ await new Promise((resolve) => setTimeout(resolve, 150));
94
+ send({ type: "message_update", assistantMessageEvent: { type: "text_end", contentIndex: 0, content: "stream block that must be replaced" } });
95
+ send({
96
+ type: "message_end",
97
+ message: { role: "assistant", stopReason: "toolUse", content: [{ type: "text", text: "authoritative first assistant message" }], usage: usage(10, 2) },
98
+ });
99
+ send({ type: "tool_execution_start", toolCallId: "fake-presentation-read", toolName: "read", args: { path: "presentation.txt" } });
100
+ send({
101
+ type: "tool_execution_update",
102
+ toolCallId: "fake-presentation-read",
103
+ toolName: "read",
104
+ args: { path: "presentation.txt" },
105
+ partialResult: { content: [{ type: "text", text: "partial read result that must be replaced" }] },
106
+ });
107
+ // Keep the partial tool result observable before the authoritative result replaces it.
108
+ await new Promise((resolve) => setTimeout(resolve, 150));
109
+ send({
110
+ type: "tool_execution_end",
111
+ toolCallId: "fake-presentation-read",
112
+ toolName: "read",
113
+ isError: false,
114
+ result: {
115
+ content: [{ type: "text", text: "final read result one\nfinal read result two\nfinal read result three\nfinal read result four\nfinal read result five\nfinal read result six \u001b[31mred\u001b[0m" }],
116
+ usage: usage(3, 1),
117
+ },
118
+ });
119
+ process.send?.({ type: "task_dispatcher_boundary", turnIndex: 0, toolResults: 1 });
120
+ send({ type: "message_end", message: { role: "assistant", stopReason: "error", errorMessage: "This operation was aborted", content: [] } });
121
+ send({ type: "agent_settled" });
122
+ return;
123
+ }
124
+ const longReport = [
125
+ "presentation opening",
126
+ "A".repeat(2_600),
127
+ "MIDDLE-ONLY-IN-EXPANDED-PRESENTATION",
128
+ "B".repeat(4_000),
129
+ "latest-presentation-marker",
130
+ ].join("\n");
131
+ send({ type: "message_start", message: { role: "assistant" } });
132
+ send({ type: "message_update", assistantMessageEvent: { type: "text_start", contentIndex: 0 } });
133
+ send({ type: "message_update", assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "partial final assistant text" } });
134
+ send({ type: "message_update", assistantMessageEvent: { type: "text_end", contentIndex: 0, content: "replaced final assistant text" } });
135
+ send({
136
+ type: "message_end",
137
+ message: { role: "assistant", stopReason: "stop", content: [{ type: "text", text: longReport }], usage: usage(5, 3) },
138
+ });
139
+ send({ type: "agent_settled" });
140
+ return;
141
+ }
68
142
  if (scenario === "length" && promptCount === 1) {
69
143
  send({
70
144
  type: "message_end",
@@ -22,6 +22,8 @@ async function resolvePiCli() {
22
22
  const cliPath = await resolvePiCli();
23
23
  const temporaryDirectory = await mkdtemp(path.join(tmpdir(), "pi-task-dispatcher-gate-probe-"));
24
24
  const gatePath = path.join(temporaryDirectory, "gate.ts");
25
+ const codemodeOnlyPath = path.join(temporaryDirectory, "codemode-only.ts");
26
+ const readProbePath = path.join(temporaryDirectory, "codemode-read-probe.txt");
25
27
  let child;
26
28
 
27
29
  try {
@@ -43,6 +45,18 @@ export default function (pi: ExtensionAPI): void {
43
45
  `,
44
46
  "utf8",
45
47
  );
48
+ // Use Pi's public factory so only codemode is model-visible while selected direct tools remain script-callable.
49
+ await writeFile(
50
+ codemodeOnlyPath,
51
+ `import { createCodemodeExtension, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
52
+ const codemodeOnly = createCodemodeExtension({ mode: "only" });
53
+ export default function codemodeOnlyExtension(pi: ExtensionAPI): void {
54
+ codemodeOnly(pi);
55
+ }
56
+ `,
57
+ "utf8",
58
+ );
59
+ await writeFile(readProbePath, "native-codemode-only-read-ready\n", "utf8");
46
60
 
47
61
  // Prefer explicit test overrides, then the active parent Pi model exported in PI_* variables.
48
62
  const activeModel = process.env.PI_TASK_DISPATCHER_GATE_MODEL
@@ -55,9 +69,9 @@ export default function (pi: ExtensionAPI): void {
55
69
  "--no-session",
56
70
  "--no-extensions",
57
71
  "--tools",
58
- "codemode",
72
+ "codemode,read",
59
73
  "--extension",
60
- "builtin:codemode",
74
+ codemodeOnlyPath,
61
75
  "--extension",
62
76
  gatePath,
63
77
  ];
@@ -94,11 +108,11 @@ export default function (pi: ExtensionAPI): void {
94
108
  });
95
109
  child.on("message", (message) => boundaries.push(message));
96
110
 
97
- // Run one codemode batch and require the gate to settle before a productive follow-up turn.
111
+ // Run one codemode batch that invokes the selected direct read tool from the script, then require the gate to settle.
98
112
  child.stdin.write(`${JSON.stringify({
99
113
  id: "probe-1",
100
114
  type: "prompt",
101
- message: "Call codemode exactly once with the raw JavaScript source: text('pi-task-dispatcher-codemode-ready')\nDo not use any other tool and do not add other script statements.",
115
+ message: `Call codemode exactly once. In its JavaScript source, call await tools.read({ path: ${JSON.stringify(readProbePath)} }); and then call text("pi-task-dispatcher-codemode-ready"). Do not call tools directly and do not make any other tool calls.`,
102
116
  })}\n`);
103
117
  await Promise.race([
104
118
  settled,
@@ -112,7 +126,13 @@ export default function (pi: ExtensionAPI): void {
112
126
  throw new Error("The first run did not execute the expected tool-use turn.");
113
127
  }
114
128
  if (!events.some((event) => event.type === "tool_execution_end" && event.toolName === "codemode" && !event.isError)) {
115
- throw new Error("The isolated worker did not complete the selected built-in codemode tool.");
129
+ throw new Error("The isolated worker did not complete the selected native codemode tool.");
130
+ }
131
+ if (!events.some((event) => event.type === "tool_execution_end" && event.toolName === "read" && event.parentToolCallId)) {
132
+ throw new Error("The codemode script could not call the explicitly selected direct read tool.");
133
+ }
134
+ if (events.some((event) => event.type === "tool_execution_start" && event.toolName === "read" && !event.parentToolCallId)) {
135
+ throw new Error("The worker called read directly instead of routing it through codemode-only mode.");
116
136
  }
117
137
 
118
138
  // Continue in the same in-memory RPC session and require a normal final answer without another tool batch.
@@ -123,7 +143,7 @@ export default function (pi: ExtensionAPI): void {
123
143
  child.stdin.write(`${JSON.stringify({
124
144
  id: "probe-2",
125
145
  type: "prompt",
126
- message: "Continue from the completed codemode result. Do not call tools. Report only the exact marker produced by the script.",
146
+ message: "Continue from the completed native codemode result. Do not call tools. Report only the exact marker produced by the script.",
127
147
  })}\n`);
128
148
  await Promise.race([
129
149
  settled,
@@ -139,7 +159,7 @@ export default function (pi: ExtensionAPI): void {
139
159
  .trim();
140
160
  if (!finalText.includes("pi-task-dispatcher-codemode-ready")) throw new Error(`Unexpected continuation output: ${finalText}`);
141
161
 
142
- console.log(JSON.stringify({ firstBoundaryCount, firstAssistantMessages: firstAssistantMessages.length, finalText }, null, 2));
162
+ console.log(JSON.stringify({ firstBoundaryCount, firstAssistantMessages: firstAssistantMessages.length, finalText, nativeCodemodeOnly: true }, null, 2));
143
163
  if (stderr.trim()) console.error(stderr.trim());
144
164
  } finally {
145
165
  // Close the reusable child and remove only the runtime-controlled probe directory.
@@ -27,7 +27,9 @@ for (const required of [
27
27
  "delegate_designer",
28
28
  "delegate_control",
29
29
  "delegate_status",
30
- "builtin:codemode",
30
+ "codemode-only.ts",
31
+ "createCodemodeExtension",
32
+ 'mode: "only"',
31
33
  "task_dispatcher_boundary",
32
34
  "without an enforced IPC boundary",
33
35
  "--no-extensions",
@@ -35,9 +37,21 @@ for (const required of [
35
37
  "set_auto_compaction",
36
38
  "get_session_stats",
37
39
  "renderResult",
40
+ "truncateToVisualLines",
41
+ "truncateToWidth",
42
+ 'keyHint("app.tools.expand", "to expand")',
43
+ "stripTerminalSequences",
44
+ "message_update",
45
+ "tool_execution_update",
46
+ "PRESENTATION_UPDATE_THROTTLE_MS",
47
+ "context.lastComponent",
38
48
  ]) {
39
49
  if (!source.includes(required)) throw new Error(`Missing supervised-protocol marker: ${required}`);
40
50
  }
41
51
 
42
- if (!String(packageJson.version).startsWith("3.")) throw new Error(`Expected a v3 package version, found ${packageJson.version}`);
52
+ if (packageJson.version !== "3.1.0") throw new Error(`Expected release version 3.1.0, found ${packageJson.version}`);
53
+ if (source.includes("builtin:codemode")) throw new Error("The dispatcher must not load codemode through a built-in extension selector.");
54
+ if (!gateProbe.includes("createCodemodeExtension") || !gateProbe.includes('mode: "only"')) {
55
+ throw new Error("The live gate probe does not exercise the native codemode-only extension.");
56
+ }
43
57
  console.log(`Static supervised-protocol checks passed for ${packageJson.name}@${packageJson.version}.`);