@oh-my-pi/pi-coding-agent 15.10.11 → 15.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/dist/cli.js +5790 -5731
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/cli-commands.d.ts +12 -0
  7. package/dist/types/commands/launch.d.ts +4 -0
  8. package/dist/types/config/api-key-resolver.d.ts +3 -0
  9. package/dist/types/config/keybindings.d.ts +6 -1
  10. package/dist/types/config/model-registry.d.ts +1 -0
  11. package/dist/types/config/model-resolver.d.ts +18 -0
  12. package/dist/types/config/settings-schema.d.ts +85 -34
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  15. package/dist/types/eval/py/executor.d.ts +5 -0
  16. package/dist/types/eval/py/kernel.d.ts +6 -1
  17. package/dist/types/eval/py/runtime.d.ts +9 -0
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/export/html/template.generated.d.ts +1 -1
  20. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/extensibility/shared-events.d.ts +2 -2
  24. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  25. package/dist/types/internal-urls/index.d.ts +1 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/irc/bus.d.ts +66 -0
  28. package/dist/types/memory-backend/index.d.ts +1 -0
  29. package/dist/types/memory-backend/runtime.d.ts +4 -0
  30. package/dist/types/memory-backend/types.d.ts +66 -1
  31. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  32. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  34. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  35. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  36. package/dist/types/modes/components/welcome.d.ts +3 -9
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  38. package/dist/types/modes/index.d.ts +3 -3
  39. package/dist/types/modes/interactive-mode.d.ts +10 -4
  40. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  41. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  42. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  43. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  44. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  45. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  46. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  47. package/dist/types/modes/theme/theme.d.ts +2 -1
  48. package/dist/types/modes/types.d.ts +5 -2
  49. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  50. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  51. package/dist/types/registry/agent-registry.d.ts +16 -5
  52. package/dist/types/secrets/index.d.ts +1 -1
  53. package/dist/types/secrets/obfuscator.d.ts +8 -2
  54. package/dist/types/session/agent-session.d.ts +49 -32
  55. package/dist/types/session/messages.d.ts +2 -4
  56. package/dist/types/session/session-history-format.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +21 -3
  58. package/dist/types/session/streaming-output.d.ts +46 -0
  59. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  60. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  61. package/dist/types/slash-commands/types.d.ts +1 -1
  62. package/dist/types/system-prompt.d.ts +2 -0
  63. package/dist/types/task/executor.d.ts +12 -2
  64. package/dist/types/task/index.d.ts +13 -6
  65. package/dist/types/task/output-manager.d.ts +0 -7
  66. package/dist/types/task/repair-args.d.ts +8 -7
  67. package/dist/types/task/types.d.ts +63 -51
  68. package/dist/types/thinking.d.ts +4 -0
  69. package/dist/types/tiny/title-client.d.ts +11 -0
  70. package/dist/types/tiny/title-protocol.d.ts +1 -0
  71. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  72. package/dist/types/tools/find.d.ts +0 -11
  73. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  74. package/dist/types/tools/index.d.ts +7 -3
  75. package/dist/types/tools/irc.d.ts +76 -38
  76. package/dist/types/tools/job.d.ts +7 -1
  77. package/dist/types/utils/git.d.ts +15 -2
  78. package/dist/types/utils/title-generator.d.ts +3 -2
  79. package/examples/extensions/with-deps/package.json +1 -0
  80. package/package.json +11 -10
  81. package/scripts/bundle-dist.ts +28 -19
  82. package/src/async/index.ts +0 -1
  83. package/src/auto-thinking/classifier.ts +1 -0
  84. package/src/cli/args.ts +3 -0
  85. package/src/cli/gallery-cli.ts +1 -1
  86. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  87. package/src/cli/gallery-fixtures/types.ts +5 -0
  88. package/src/cli-commands.ts +29 -0
  89. package/src/cli.ts +28 -15
  90. package/src/commands/launch.ts +4 -0
  91. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  92. package/src/commit/model-selection.ts +3 -2
  93. package/src/config/api-key-resolver.ts +8 -6
  94. package/src/config/keybindings.ts +6 -1
  95. package/src/config/model-registry.ts +97 -30
  96. package/src/config/model-resolver.ts +60 -0
  97. package/src/config/settings-schema.ts +99 -55
  98. package/src/config/settings.ts +68 -3
  99. package/src/edit/hashline/execute.ts +39 -2
  100. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  101. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  102. package/src/eval/agent-bridge.ts +3 -16
  103. package/src/eval/completion-bridge.ts +1 -0
  104. package/src/eval/js/shared/prelude.txt +1 -1
  105. package/src/eval/py/executor.ts +29 -7
  106. package/src/eval/py/index.ts +6 -1
  107. package/src/eval/py/kernel.ts +31 -11
  108. package/src/eval/py/prelude.py +5 -6
  109. package/src/eval/py/runtime.ts +37 -0
  110. package/src/exec/bash-executor.ts +82 -3
  111. package/src/export/html/template.generated.ts +1 -1
  112. package/src/export/html/template.js +38 -13
  113. package/src/extensibility/custom-tools/types.ts +2 -2
  114. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  115. package/src/extensibility/extensions/runner.ts +6 -1
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/shared-events.ts +2 -2
  118. package/src/hindsight/bank.ts +17 -2
  119. package/src/internal-urls/docs-index.generated.ts +11 -11
  120. package/src/internal-urls/history-protocol.ts +113 -0
  121. package/src/internal-urls/index.ts +1 -0
  122. package/src/internal-urls/router.ts +3 -1
  123. package/src/internal-urls/types.ts +1 -1
  124. package/src/irc/bus.ts +292 -0
  125. package/src/main.ts +26 -66
  126. package/src/memories/index.ts +2 -0
  127. package/src/memory-backend/index.ts +1 -0
  128. package/src/memory-backend/local-backend.ts +9 -0
  129. package/src/memory-backend/off-backend.ts +9 -0
  130. package/src/memory-backend/runtime.ts +66 -0
  131. package/src/memory-backend/types.ts +81 -1
  132. package/src/mnemopi/backend.ts +151 -4
  133. package/src/modes/acp/acp-agent.ts +119 -11
  134. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  135. package/src/modes/components/assistant-message.ts +19 -21
  136. package/src/modes/components/compaction-summary-message.ts +68 -32
  137. package/src/modes/components/custom-editor.ts +10 -0
  138. package/src/modes/components/footer.ts +3 -1
  139. package/src/modes/components/status-line/component.ts +118 -34
  140. package/src/modes/components/tool-execution.ts +31 -1
  141. package/src/modes/components/ttsr-notification.ts +72 -30
  142. package/src/modes/components/welcome.ts +9 -33
  143. package/src/modes/controllers/command-controller.ts +1 -1
  144. package/src/modes/controllers/event-controller.ts +65 -0
  145. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  146. package/src/modes/controllers/input-controller.ts +19 -2
  147. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  148. package/src/modes/controllers/selector-controller.ts +21 -17
  149. package/src/modes/index.ts +3 -21
  150. package/src/modes/interactive-mode.ts +47 -22
  151. package/src/modes/oauth-manual-input.ts +30 -3
  152. package/src/modes/rpc/rpc-client.ts +154 -3
  153. package/src/modes/rpc/rpc-mode.ts +97 -12
  154. package/src/modes/rpc/rpc-subagents.ts +265 -0
  155. package/src/modes/rpc/rpc-types.ts +81 -1
  156. package/src/modes/setup-wizard/index.ts +12 -2
  157. package/src/modes/setup-wizard/lazy.ts +16 -0
  158. package/src/modes/theme/theme.ts +18 -5
  159. package/src/modes/types.ts +5 -5
  160. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  161. package/src/modes/utils/ui-helpers.ts +51 -49
  162. package/src/prompts/system/irc-incoming.md +3 -4
  163. package/src/prompts/system/orchestrate-notice.md +2 -2
  164. package/src/prompts/system/subagent-system-prompt.md +0 -5
  165. package/src/prompts/system/system-prompt.md +1 -0
  166. package/src/prompts/system/workflow-notice.md +2 -2
  167. package/src/prompts/tools/eval.md +3 -3
  168. package/src/prompts/tools/irc.md +29 -19
  169. package/src/prompts/tools/read.md +2 -2
  170. package/src/prompts/tools/task-summary.md +5 -16
  171. package/src/prompts/tools/task.md +38 -29
  172. package/src/registry/agent-lifecycle.ts +218 -0
  173. package/src/registry/agent-registry.ts +16 -5
  174. package/src/sdk.ts +37 -10
  175. package/src/secrets/index.ts +8 -1
  176. package/src/secrets/obfuscator.ts +39 -18
  177. package/src/session/agent-session.ts +422 -291
  178. package/src/session/messages.ts +11 -78
  179. package/src/session/session-history-format.ts +246 -0
  180. package/src/session/session-manager.ts +59 -5
  181. package/src/session/streaming-output.ts +226 -10
  182. package/src/slash-commands/acp-builtins.ts +24 -0
  183. package/src/slash-commands/builtin-registry.ts +20 -0
  184. package/src/slash-commands/types.ts +1 -1
  185. package/src/system-prompt.ts +14 -0
  186. package/src/task/executor.ts +851 -461
  187. package/src/task/index.ts +721 -796
  188. package/src/task/output-manager.ts +0 -11
  189. package/src/task/render.ts +148 -63
  190. package/src/task/repair-args.ts +21 -9
  191. package/src/task/types.ts +82 -66
  192. package/src/thinking.ts +7 -0
  193. package/src/tiny/title-client.ts +34 -5
  194. package/src/tiny/title-protocol.ts +1 -1
  195. package/src/tiny/worker.ts +6 -4
  196. package/src/tools/ask.ts +4 -2
  197. package/src/tools/bash.ts +61 -10
  198. package/src/tools/browser/tab-worker.ts +26 -7
  199. package/src/tools/browser.ts +28 -1
  200. package/src/tools/find.ts +2 -27
  201. package/src/tools/grouped-file-output.ts +1 -118
  202. package/src/tools/image-gen.ts +11 -4
  203. package/src/tools/index.ts +17 -13
  204. package/src/tools/inspect-image.ts +1 -0
  205. package/src/tools/irc.ts +596 -171
  206. package/src/tools/job.ts +41 -7
  207. package/src/tools/read.ts +57 -1
  208. package/src/tools/renderers.ts +2 -0
  209. package/src/tools/resolve.ts +4 -1
  210. package/src/utils/commit-message-generator.ts +1 -0
  211. package/src/utils/git.ts +267 -13
  212. package/src/utils/title-generator.ts +24 -5
  213. package/dist/types/async/support.d.ts +0 -2
  214. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  215. package/dist/types/task/simple-mode.d.ts +0 -8
  216. package/src/async/support.ts +0 -5
  217. package/src/task/simple-mode.ts +0 -27
package/src/thinking.ts CHANGED
@@ -71,6 +71,13 @@ export function toReasoningEffort(level: ThinkingLevel | undefined): Effort | un
71
71
  return level;
72
72
  }
73
73
 
74
+ /**
75
+ * True when a selector explicitly requests provider-side reasoning disablement.
76
+ */
77
+ export function shouldDisableReasoning(level: ThinkingLevel | undefined): boolean {
78
+ return level === ThinkingLevel.Off;
79
+ }
80
+
74
81
  /**
75
82
  * Resolves a selector against the current model while preserving explicit "off".
76
83
  */
@@ -39,6 +39,17 @@ export interface TinyTitleDownloadOptions {
39
39
  onProgress?: (event: TinyTitleProgressEvent) => void;
40
40
  }
41
41
 
42
+ /**
43
+ * Per-request controls for {@link TinyTitleClient.generate}.
44
+ *
45
+ * Carries the optional abort signal and title-system-prompt override used by
46
+ * callers that customize automatic session-title generation.
47
+ */
48
+ export interface TinyTitleGenerateOptions {
49
+ signal?: AbortSignal;
50
+ systemPrompt?: string;
51
+ }
52
+
42
53
  // Cold-starting the worker subprocess from a compiled binary (decompress + module
43
54
  // graph load) is slow on contended CI runners — the macos-15-intel release smoke
44
55
  // blew past 5s while arm64/linux/win passed. The probe only needs to prove the
@@ -46,6 +57,14 @@ export interface TinyTitleDownloadOptions {
46
57
  // generous bound removes the flake without weakening the check.
47
58
  const SMOKE_TEST_TIMEOUT_MS = 30_000;
48
59
 
60
+ function normalizeTinyTitleGenerateOptions(
61
+ options: AbortSignal | TinyTitleGenerateOptions | undefined,
62
+ ): TinyTitleGenerateOptions {
63
+ if (!options) return {};
64
+ if ("aborted" in options && "addEventListener" in options) return { signal: options };
65
+ return options;
66
+ }
67
+
49
68
  /**
50
69
  * Hidden subcommand on the main CLI that boots the tiny-model worker in the
51
70
  * spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
@@ -295,9 +314,16 @@ export class TinyTitleClient {
295
314
  return () => this.#progressListeners.delete(listener);
296
315
  }
297
316
 
298
- async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null> {
317
+ async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null>;
318
+ async generate(modelKey: string, message: string, options?: TinyTitleGenerateOptions): Promise<string | null>;
319
+ async generate(
320
+ modelKey: string,
321
+ message: string,
322
+ optionsOrSignal?: AbortSignal | TinyTitleGenerateOptions,
323
+ ): Promise<string | null> {
324
+ const options = normalizeTinyTitleGenerateOptions(optionsOrSignal);
299
325
  if (!isTinyTitleLocalModelKey(modelKey)) return null;
300
- if (signal?.aborted) return null;
326
+ if (options.signal?.aborted) return null;
301
327
 
302
328
  try {
303
329
  const worker = this.#ensureWorker();
@@ -310,12 +336,15 @@ export class TinyTitleClient {
310
336
  this.#pending.delete(id);
311
337
  pending.resolve(null);
312
338
  };
313
- signal?.addEventListener("abort", abort, { once: true });
339
+ options.signal?.addEventListener("abort", abort, { once: true });
314
340
  try {
315
- worker.send({ type: "generate", id, modelKey, message });
341
+ const request: TinyTitleWorkerInbound = options.systemPrompt
342
+ ? { type: "generate", id, modelKey, message, systemPrompt: options.systemPrompt }
343
+ : { type: "generate", id, modelKey, message };
344
+ worker.send(request);
316
345
  return await promise;
317
346
  } finally {
318
- signal?.removeEventListener("abort", abort);
347
+ options.signal?.removeEventListener("abort", abort);
319
348
  this.#pending.delete(id);
320
349
  }
321
350
  } catch (error) {
@@ -29,7 +29,7 @@ export interface TinyTitleProgressEvent {
29
29
 
30
30
  export type TinyTitleWorkerInbound =
31
31
  | { type: "ping"; id: string }
32
- | { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
32
+ | { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string; systemPrompt?: string }
33
33
  | { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
34
34
  | { type: "download"; id: string; modelKey: TinyLocalModelKey };
35
35
 
@@ -436,9 +436,10 @@ async function loadPipeline(
436
436
  return loaded;
437
437
  }
438
438
 
439
- function buildPrompt(generator: TextGenerationPipeline, message: string): string {
439
+ function buildPrompt(generator: TextGenerationPipeline, message: string, systemPrompt?: string): string {
440
+ const selectedSystemPrompt = systemPrompt?.trim() || TINY_TITLE_SYSTEM_PROMPT;
440
441
  const chat = [
441
- { role: "system", content: TINY_TITLE_SYSTEM_PROMPT },
442
+ { role: "system", content: selectedSystemPrompt },
442
443
  { role: "user", content: formatTitleUserMessage(message) },
443
444
  ];
444
445
  const chatTemplateOptions = {
@@ -464,9 +465,10 @@ async function generateTitle(
464
465
  requestId: string,
465
466
  modelKey: TinyTitleLocalModelKey,
466
467
  message: string,
468
+ systemPrompt?: string,
467
469
  ): Promise<string | null> {
468
470
  const generator = await loadPipeline(modelKey, transport, requestId);
469
- const promptText = buildPrompt(generator, message);
471
+ const promptText = buildPrompt(generator, message, systemPrompt);
470
472
  const transformers = await loadTransformers(transport, requestId, modelKey);
471
473
  const output = (await generator(promptText, {
472
474
  max_new_tokens: TITLE_MAX_NEW_TOKENS,
@@ -548,7 +550,7 @@ async function handleQueuedRequest(
548
550
  transport.send({ type: "completion", id: request.id, text });
549
551
  return;
550
552
  }
551
- const title = await generateTitle(transport, request.id, request.modelKey, request.message);
553
+ const title = await generateTitle(transport, request.id, request.modelKey, request.message, request.systemPrompt);
552
554
  transport.send({ type: "title", id: request.id, title });
553
555
  } catch (error) {
554
556
  transport.send({ type: "error", id: request.id, error: errorText(error) });
package/src/tools/ask.ts CHANGED
@@ -104,7 +104,7 @@ const RECOMMENDED_SUFFIX = " (Recommended)";
104
104
  const TIMEOUT_DETECTION_TOLERANCE_MS = 1_000;
105
105
 
106
106
  function getDoneOptionLabel(): string {
107
- return `${theme.symbol("tool.ask")} Done selecting`;
107
+ return `${theme.status.success} Done selecting`;
108
108
  }
109
109
 
110
110
  /** Add "(Recommended)" suffix to the option at the given index if not already present */
@@ -694,7 +694,9 @@ function normalizeRenderQuestions(raw: unknown): NonNullable<AskRenderArgs["ques
694
694
  /** Render a custom free-text answer as a status line plus indented continuation rows. */
695
695
  function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] {
696
696
  const lines = customInput.split("\n");
697
- const out: string[] = [` ${uiTheme.styledSymbol("tool.ask", "accent")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`];
697
+ const out: string[] = [
698
+ ` ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`,
699
+ ];
698
700
  for (let i = 1; i < lines.length; i++) out.push(` ${uiTheme.fg("toolOutput", lines[i])}`);
699
701
  return out;
700
702
  }
package/src/tools/bash.ts CHANGED
@@ -17,7 +17,7 @@ import { truncateToVisualLines } from "../modes/components/visual-truncate";
17
17
  import { highlightCode, type Theme } from "../modes/theme/theme";
18
18
  import bashDescription from "../prompts/tools/bash.md" with { type: "text" };
19
19
  import type { ClientBridgeTerminalExitStatus, ClientBridgeTerminalOutput } from "../session/client-bridge";
20
- import { DEFAULT_MAX_BYTES, streamTailUpdates, TailBuffer } from "../session/streaming-output";
20
+ import { DEFAULT_MAX_BYTES, enforceInlineByteCap, streamTailUpdates, TailBuffer } from "../session/streaming-output";
21
21
  import { renderStatusLine } from "../tui";
22
22
  import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
23
23
  import { getSixelLineMask } from "../utils/sixel";
@@ -429,7 +429,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
429
429
  }
430
430
  }
431
431
 
432
- #buildCompletedResult(
432
+ async #buildCompletedResult(
433
433
  result: BashResult | BashInteractiveResult,
434
434
  timeoutSec: number,
435
435
  options: {
@@ -438,7 +438,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
438
438
  terminalId?: string;
439
439
  wallTimeMs?: number;
440
440
  } = {},
441
- ): AgentToolResult<BashToolDetails> {
441
+ ): Promise<AgentToolResult<BashToolDetails>> {
442
442
  const exitCode = result.exitCode;
443
443
  const failedExit = exitCode !== undefined && exitCode !== 0;
444
444
 
@@ -472,7 +472,17 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
472
472
  if (failedExit) {
473
473
  details.exitCode = exitCode;
474
474
  }
475
- const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
475
+ // Final defense at the tool-result boundary: no bash path (client bridge,
476
+ // head-retention spill, minimizer miss) may emit more than
477
+ // ~DEFAULT_MAX_BYTES inline. No-op for already-bounded output.
478
+ const cappedOutputText = await enforceInlineByteCap(outputText, {
479
+ label: "bash output",
480
+ saveArtifact: full => saveBashOriginalArtifact(this.session, full),
481
+ });
482
+
483
+ const resultBuilder = toolResult(details)
484
+ .text(cappedOutputText)
485
+ .truncationFromSummary(result, { direction: "tail" });
476
486
  if (failedExit) resultBuilder.error();
477
487
  return resultBuilder.done();
478
488
  }
@@ -560,7 +570,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
560
570
  onMinimizedSave: originalText => saveBashOriginalArtifact(this.session, originalText),
561
571
  });
562
572
  const wallTimeMs = performance.now() - wallTimeStart;
563
- const finalResult = this.#buildCompletedResult(result, options.timeoutSec, {
573
+ const finalResult = await this.#buildCompletedResult(result, options.timeoutSec, {
564
574
  requestedTimeoutSec: options.requestedTimeoutSec,
565
575
  notices: options.notices ?? [],
566
576
  wallTimeMs,
@@ -1212,6 +1222,22 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1212
1222
  const details = result.details;
1213
1223
  const outputBlock = new CachedOutputBlock();
1214
1224
 
1225
+ // Per-instance cache for the expensive inner lines computation. Mirrors
1226
+ // the eval-renderer pattern (`eval-render.ts:709-752`): without this,
1227
+ // every TUI repaint (one per keystroke when a long transcript is on
1228
+ // screen) re-runs `split` / `replaceTabs` / `truncateToVisualLines` over
1229
+ // the whole stored output for every bash row in scrollback. With a
1230
+ // 50KB-tail bash result times hundreds of rows, that re-rendering is
1231
+ // what pinned the main thread in issue #2081 and made keystrokes feel
1232
+ // like the CPU was at 100%. The cache key includes every render input
1233
+ // that materially affects the produced lines.
1234
+ let cachedWidth: number | undefined;
1235
+ let cachedPreviewLines: number | undefined;
1236
+ let cachedExpanded: boolean | undefined;
1237
+ let cachedRawOutput: string | undefined;
1238
+ let cachedIsPartial: boolean | undefined;
1239
+ let cachedLines: readonly string[] | undefined;
1240
+
1215
1241
  return markFramedBlockComponent({
1216
1242
  render: (width: number): readonly string[] => {
1217
1243
  // REACTIVE: read mutable options at render time
@@ -1223,6 +1249,19 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1223
1249
  // Strip the LLM-facing notice appended by wrappedExecute so we don't
1224
1250
  // double-print it alongside the styled warning line below.
1225
1251
  const rawOutput = renderContext?.output ?? result.content?.find(c => c.type === "text")?.text ?? "";
1252
+
1253
+ const isPartial = options.isPartial === true;
1254
+
1255
+ if (
1256
+ cachedLines !== undefined &&
1257
+ cachedWidth === width &&
1258
+ cachedPreviewLines === previewLines &&
1259
+ cachedExpanded === expanded &&
1260
+ cachedRawOutput === rawOutput &&
1261
+ cachedIsPartial === isPartial
1262
+ ) {
1263
+ return cachedLines;
1264
+ }
1226
1265
  const strippedOutput = stripOutputNotice(rawOutput, details?.meta);
1227
1266
  const withoutExit = stripExitCodeNotice(strippedOutput, details?.exitCode);
1228
1267
  const withoutWall = stripWallTimeNotice(withoutExit, details?.wallTimeMs);
@@ -1299,15 +1338,13 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1299
1338
  if (timeoutLine) outputLines.push(timeoutLine);
1300
1339
  if (warningLine) outputLines.push(warningLine);
1301
1340
 
1302
- return outputBlock.render(
1341
+ const framed = outputBlock.render(
1303
1342
  {
1304
1343
  header,
1305
- state: options.isPartial ? "pending" : isError ? "error" : "success",
1344
+ state: isPartial ? "pending" : isError ? "error" : "success",
1306
1345
  sections: [
1307
1346
  {
1308
- lines: options.isPartial
1309
- ? capPreviewLines(cmdLines ?? [], uiTheme, { expanded })
1310
- : (cmdLines ?? []),
1347
+ lines: isPartial ? capPreviewLines(cmdLines ?? [], uiTheme, { expanded }) : (cmdLines ?? []),
1311
1348
  },
1312
1349
  { label: uiTheme.fg("toolTitle", "Output"), lines: outputLines },
1313
1350
  ],
@@ -1315,9 +1352,23 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
1315
1352
  },
1316
1353
  uiTheme,
1317
1354
  );
1355
+
1356
+ cachedWidth = width;
1357
+ cachedPreviewLines = previewLines;
1358
+ cachedExpanded = expanded;
1359
+ cachedRawOutput = rawOutput;
1360
+ cachedIsPartial = isPartial;
1361
+ cachedLines = framed;
1362
+ return framed;
1318
1363
  },
1319
1364
  invalidate: () => {
1320
1365
  outputBlock.invalidate();
1366
+ cachedLines = undefined;
1367
+ cachedWidth = undefined;
1368
+ cachedPreviewLines = undefined;
1369
+ cachedExpanded = undefined;
1370
+ cachedRawOutput = undefined;
1371
+ cachedIsPartial = undefined;
1321
1372
  },
1322
1373
  });
1323
1374
  },
@@ -10,6 +10,7 @@ import type {
10
10
  ElementHandle,
11
11
  ElementScreenshotOptions,
12
12
  HTTPResponse,
13
+ ImageFormat,
13
14
  KeyInput,
14
15
  Page,
15
16
  SerializedAXNode,
@@ -436,6 +437,19 @@ export function describeScreenshot(opts?: ScreenshotOptions): string {
436
437
  return "tab.screenshot()";
437
438
  }
438
439
 
440
+ /** Map an explicit save path's extension to a puppeteer capture format (default png). */
441
+ export function imageFormatForPath(filePath: string): ImageFormat {
442
+ switch (path.extname(filePath).toLowerCase()) {
443
+ case ".webp":
444
+ return "webp";
445
+ case ".jpg":
446
+ case ".jpeg":
447
+ return "jpeg";
448
+ default:
449
+ return "png";
450
+ }
451
+ }
452
+
439
453
  /** Summarize still-running helpers (oldest first) so a cell timeout names what stalled. */
440
454
  export function describeInflight(inflight: Map<number, InflightOp>): string {
441
455
  const now = Date.now();
@@ -931,6 +945,12 @@ export class WorkerCore {
931
945
  ): Promise<ScreenshotResult> {
932
946
  const page = this.#requirePage();
933
947
  const fullPage = opts.selector ? false : (opts.fullPage ?? false);
948
+ // An explicit save path picks the full-res capture format: puppeteer encodes
949
+ // png/jpeg/webp natively, so `save: "shot.webp"` gets real WebP bytes instead
950
+ // of PNG bytes hiding behind a .webp name. Unknown/missing extensions stay PNG.
951
+ const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
952
+ const captureType = explicitPath ? imageFormatForPath(explicitPath) : "png";
953
+ const captureMime = `image/${captureType}` as const;
934
954
  let buffer: Buffer;
935
955
  if (opts.selector) {
936
956
  const handle = (await untilAborted(signal, () =>
@@ -951,24 +971,23 @@ export class WorkerCore {
951
971
  ).catch(() => undefined);
952
972
  // scrollIntoView:false skips the same IntersectionObserver check inside screenshot();
953
973
  // captureBeyondViewport (puppeteer's default) still renders the clipped region.
954
- const shotOpts: ElementScreenshotOptions = { type: "png", scrollIntoView: false };
974
+ const shotOpts: ElementScreenshotOptions = { type: captureType, scrollIntoView: false };
955
975
  buffer = (await untilAborted(signal, () => handle.screenshot(shotOpts))) as Buffer;
956
976
  } finally {
957
977
  await handle.dispose().catch(() => undefined);
958
978
  }
959
979
  } else {
960
- buffer = (await untilAborted(signal, () => page.screenshot({ type: "png", fullPage }))) as Buffer;
980
+ buffer = (await untilAborted(signal, () => page.screenshot({ type: captureType, fullPage }))) as Buffer;
961
981
  }
962
982
  const resized = await resizeImage(
963
- { type: "image", data: buffer.toBase64(), mimeType: "image/png" },
983
+ { type: "image", data: buffer.toBase64(), mimeType: captureMime },
964
984
  { maxWidth: 1024, maxHeight: 1024, maxBytes: 150 * 1024, jpegQuality: 70 },
965
985
  );
966
- const explicitPath = opts.save ? resolveToCwd(opts.save, session.cwd) : undefined;
967
986
  const saveFullRes = !!(explicitPath || session.browserScreenshotDir);
968
987
  const savedBuffer = saveFullRes ? buffer : resized.buffer;
969
- const savedMimeType = saveFullRes ? "image/png" : resized.mimeType;
970
- // Auto-generated names must match the bytes we actually write: full-res is always
971
- // PNG, but the resized buffer is whichever of PNG/JPEG/WebP encoded smallest.
988
+ const savedMimeType = saveFullRes ? captureMime : resized.mimeType;
989
+ // Names must match the bytes we actually write: full-res follows the capture
990
+ // format, the resized buffer is whichever of PNG/JPEG/WebP encoded smallest.
972
991
  const ext = savedMimeType === "image/webp" ? "webp" : savedMimeType === "image/jpeg" ? "jpg" : "png";
973
992
  const dest =
974
993
  explicitPath ??
@@ -3,6 +3,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
3
3
  import * as z from "zod/v4";
4
4
  import browserDescription from "../prompts/tools/browser.md" with { type: "text" };
5
5
  import type { ToolSession } from "../sdk";
6
+ import { enforceInlineByteCap } from "../session/streaming-output";
6
7
  import { truncateForPrompt } from "./approval";
7
8
  import { acquireBrowser, type BrowserHandle, type BrowserKind, type BrowserKindTag } from "./browser/registry";
8
9
  import type { Observation, ScreenshotResult } from "./browser/tab-protocol";
@@ -271,11 +272,37 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
271
272
  .filter((c): c is { type: "text"; text: string } => c.type === "text")
272
273
  .map(c => c.text)
273
274
  .join("\n");
274
- details.result = textOnly;
275
+ // Final defense at the tool-result boundary: a single run can display
276
+ // tens of KB (large JSON returns, dumped observations). Cap the combined
277
+ // text inline; the full text stays recoverable via the artifact footer
278
+ // when allocation succeeds.
279
+ const cappedText = await enforceInlineByteCap(textOnly, {
280
+ label: "browser output",
281
+ saveArtifact: full => saveBrowserOutputArtifact(this.session, full),
282
+ });
283
+ details.result = cappedText;
284
+ if (cappedText !== textOnly) {
285
+ const nonText = content.filter(c => c.type !== "text");
286
+ return toolResult(details)
287
+ .content([...nonText, { type: "text", text: cappedText }])
288
+ .done();
289
+ }
275
290
  return toolResult(details).content(content).done();
276
291
  }
277
292
  }
278
293
 
294
+ /** Persist over-cap browser run output as a session artifact; mirrors the bash minimizer's save path. */
295
+ async function saveBrowserOutputArtifact(session: ToolSession, fullText: string): Promise<string | undefined> {
296
+ try {
297
+ const alloc = await session.allocateOutputArtifact?.("browser-original");
298
+ if (!alloc?.path || !alloc.id) return undefined;
299
+ await Bun.write(alloc.path, fullText);
300
+ return alloc.id;
301
+ } catch {
302
+ return undefined;
303
+ }
304
+ }
305
+
279
306
  function describeBrowser(handle: BrowserHandle): string {
280
307
  switch (handle.kind.kind) {
281
308
  case "headless":
package/src/tools/find.ts CHANGED
@@ -4,7 +4,7 @@ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallb
4
4
  import * as natives from "@oh-my-pi/pi-natives";
5
5
  import type { Component } from "@oh-my-pi/pi-tui";
6
6
  import { Text } from "@oh-my-pi/pi-tui";
7
- import { isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
7
+ import { formatGroupedPaths, isEnoent, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import * as z from "zod/v4";
9
9
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
10
  import { InternalUrlRouter } from "../internal-urls";
@@ -13,7 +13,6 @@ import findDescription from "../prompts/tools/find.md" with { type: "text" };
13
13
  import { type TruncationResult, truncateHead } from "../session/streaming-output";
14
14
  import { Ellipsis, fileHyperlink, renderFileList, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
15
15
  import type { ToolSession } from ".";
16
- import { buildPathTree, walkPathTree } from "./grouped-file-output";
17
16
  import { applyListLimit } from "./list-limit";
18
17
  import { formatFullOutputReference, type OutputMeta } from "./output-meta";
19
18
  import {
@@ -54,30 +53,6 @@ const DEFAULT_GLOB_TIMEOUT_MS = 5000;
54
53
  const MIN_GLOB_TIMEOUT_MS = 500;
55
54
  const MAX_GLOB_TIMEOUT_MS = 60_000;
56
55
 
57
- /**
58
- * Group find matches into a multi-level directory tree so the model doesn't pay
59
- * repeated tokens for shared path prefixes. Single-child directory chains fold
60
- * into one header (`# a/b/c/`), so a common prefix — including an absolute root
61
- * for out-of-cwd results — collapses to a single line. Each level adds one `#`;
62
- * files are listed bare under the deepest directory header that owns them.
63
- *
64
- * Order follows the input (mtime-desc for native glob): a directory appears when
65
- * its first member is emitted, and a node's own files precede its subdirectories.
66
- */
67
- export function formatFindGroupedOutput(paths: readonly string[]): string {
68
- if (paths.length === 0) return "";
69
- const tree = buildPathTree(paths.map(entry => ({ path: entry, isDir: entry.endsWith("/") })));
70
- const lines: string[] = [];
71
- for (const event of walkPathTree(tree)) {
72
- if (event.kind === "dir") {
73
- lines.push(`${"#".repeat(event.depth + 1)} ${event.name}/`);
74
- } else {
75
- lines.push(event.name);
76
- }
77
- }
78
- return lines.join("\n");
79
- }
80
-
81
56
  export interface FindToolDetails {
82
57
  truncation?: TruncationResult;
83
58
  resultLimitReached?: number;
@@ -270,7 +245,7 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
270
245
  const listLimit = applyListLimit(files, { limit: effectiveLimit });
271
246
  const limited = listLimit.items;
272
247
  const limitMeta = listLimit.meta;
273
- const baseOutput = formatFindGroupedOutput(limited);
248
+ const baseOutput = formatGroupedPaths(limited);
274
249
  const trailingNotes: string[] = [];
275
250
  if (notice) trailingNotes.push(notice);
276
251
  if (missingPathsNote) trailingNotes.push(missingPathsNote);
@@ -1,123 +1,6 @@
1
1
  import path from "node:path";
2
2
 
3
- const URL_LIKE_PATH_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
4
-
5
- function isUrlLikePath(filePath: string): boolean {
6
- return URL_LIKE_PATH_RE.test(filePath);
7
- }
8
-
9
- // =============================================================================
10
- // Multi-level path tree
11
- // =============================================================================
12
- //
13
- // File listings (grep / ast-grep / ast-edit / lsp diagnostics / find) used to
14
- // group by the *immediate* parent directory and print the full directory path in
15
- // every header. For results spread across a deep tree — or rooted outside cwd,
16
- // where paths stay absolute — that repeated the shared prefix on every line. The
17
- // tree below folds single-child directory chains (so the common prefix collapses
18
- // into one header) and nests the rest, charging the model one token per path
19
- // segment instead of one per file.
20
-
21
- interface PathTreeNode {
22
- /** Direct file leaves, in first-seen order. */
23
- files: Array<{ name: string; key: string }>;
24
- /** Dedup set for `files` (a glob can surface the same path twice on retry). */
25
- fileNames: Set<string>;
26
- /** Child directories, in first-seen order. */
27
- subdirs: Array<{ name: string; node: PathTreeNode }>;
28
- /** Dedup index for `subdirs`. */
29
- dirIndex: Map<string, PathTreeNode>;
30
- }
31
-
32
- export interface PathTreeInput {
33
- /** Path string; absolute, cwd-relative, or url-like. Backslashes are normalized. */
34
- path: string;
35
- /** Whether the leaf itself is a directory (trailing-slash match from find). */
36
- isDir: boolean;
37
- /** Opaque key carried onto file events for section lookup. Defaults to `path`. */
38
- key?: string;
39
- }
40
-
41
- /** One node emitted while walking the tree: a folded directory or a file leaf. */
42
- export interface GroupedTreeEvent {
43
- kind: "dir" | "file";
44
- /** 0-based nesting depth (root children are depth 0). */
45
- depth: number;
46
- /** Folded chain for dirs (e.g. `a/b/c`, no trailing slash); basename for files. */
47
- name: string;
48
- /** File key for `kind === "file"`; empty string for directories. */
49
- key: string;
50
- }
51
-
52
- function createNode(): PathTreeNode {
53
- return { files: [], fileNames: new Set(), subdirs: [], dirIndex: new Map() };
54
- }
55
-
56
- function addFile(node: PathTreeNode, name: string, key: string): void {
57
- if (node.fileNames.has(name)) return;
58
- node.fileNames.add(name);
59
- node.files.push({ name, key });
60
- }
61
-
62
- /**
63
- * Build a directory tree from a flat list of paths. URL-like entries are kept
64
- * whole as root-level file leaves (they have no meaningful directory structure).
65
- * Absolute paths carry a leading empty segment so they share a common `/` root
66
- * and fold like any other prefix.
67
- */
68
- export function buildPathTree(entries: Iterable<PathTreeInput>): PathTreeNode {
69
- const root = createNode();
70
- for (const { path: rawPath, isDir, key } of entries) {
71
- const normalized = rawPath.replace(/\\/g, "/");
72
- const fileKey = key ?? rawPath;
73
- if (isUrlLikePath(normalized)) {
74
- addFile(root, normalized, fileKey);
75
- continue;
76
- }
77
- const trimmed = normalized.endsWith("/") ? normalized.slice(0, -1) : normalized;
78
- if (trimmed.length === 0) continue;
79
- const segments = trimmed.split("/");
80
- const dirCount = isDir ? segments.length : segments.length - 1;
81
- let node = root;
82
- for (let i = 0; i < dirCount; i++) {
83
- const segment = segments[i]!;
84
- let child = node.dirIndex.get(segment);
85
- if (!child) {
86
- child = createNode();
87
- node.dirIndex.set(segment, child);
88
- node.subdirs.push({ name: segment, node: child });
89
- }
90
- node = child;
91
- }
92
- if (!isDir) {
93
- addFile(node, segments[segments.length - 1]!, fileKey);
94
- }
95
- }
96
- return root;
97
- }
98
-
99
- /**
100
- * Depth-first walk yielding directory and file events. Directories collapse their
101
- * single-child chains (`a` → `a/b` → `a/b/c`) so a shared prefix becomes one
102
- * header. Each node's direct files are emitted before its subdirectories, keeping
103
- * a file unambiguously attached to the header above it.
104
- */
105
- export function* walkPathTree(node: PathTreeNode, depth = 0): Generator<GroupedTreeEvent> {
106
- for (const file of node.files) {
107
- yield { kind: "file", depth, name: file.name, key: file.key };
108
- }
109
- for (const subdir of node.subdirs) {
110
- let dirNode = subdir.node;
111
- const parts = [subdir.name];
112
- while (dirNode.files.length === 0 && dirNode.subdirs.length === 1) {
113
- const only = dirNode.subdirs[0]!;
114
- parts.push(only.name);
115
- dirNode = only.node;
116
- }
117
- yield { kind: "dir", depth, name: parts.join("/"), key: "" };
118
- yield* walkPathTree(dirNode, depth + 1);
119
- }
120
- }
3
+ import { buildPathTree, isUrlLikePath, type PathTreeInput, walkPathTree } from "@oh-my-pi/pi-utils";
121
4
 
122
5
  // =============================================================================
123
6
  // Grouped file output (grep / ast-grep / ast-edit / lsp diagnostics)
@@ -472,8 +472,13 @@ function parseAntigravityCredentials(raw: string): ParsedAntigravityCredentials
472
472
  return null;
473
473
  }
474
474
 
475
- async function findAntigravityCredentials(modelRegistry: ModelRegistry): Promise<ImageApiKey | null> {
476
- const apiKey = await modelRegistry.getApiKeyForProvider("google-antigravity");
475
+ async function findAntigravityCredentials(
476
+ modelRegistry: ModelRegistry,
477
+ sessionId?: string,
478
+ ): Promise<ImageApiKey | null> {
479
+ const apiKey = await modelRegistry.getApiKeyForProvider("google-antigravity", sessionId, {
480
+ modelId: DEFAULT_ANTIGRAVITY_MODEL,
481
+ });
477
482
  if (!apiKey) return null;
478
483
 
479
484
  const parsed = parseAntigravityCredentials(apiKey);
@@ -523,7 +528,7 @@ async function findImageApiKey(
523
528
  if (openAI) return openAI;
524
529
  // Fall through to auto-detect if preferred provider key not found.
525
530
  } else if (preferredImageProvider === "antigravity" && modelRegistry) {
526
- const antigravity = await findAntigravityCredentials(modelRegistry);
531
+ const antigravity = await findAntigravityCredentials(modelRegistry, sessionId);
527
532
  if (antigravity) return antigravity;
528
533
  // Fall through to auto-detect if preferred provider key not found.
529
534
  } else if (preferredImageProvider === "gemini") {
@@ -547,7 +552,7 @@ async function findImageApiKey(
547
552
  if (openAI) return openAI;
548
553
 
549
554
  if (modelRegistry) {
550
- const antigravity = await findAntigravityCredentials(modelRegistry);
555
+ const antigravity = await findAntigravityCredentials(modelRegistry, sessionId);
551
556
  if (antigravity) return antigravity;
552
557
  }
553
558
 
@@ -1052,6 +1057,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1052
1057
  const hostedKey: ApiKey = ctx.modelRegistry.resolver(hostedModel.provider, {
1053
1058
  sessionId,
1054
1059
  baseUrl: hostedModel.baseUrl,
1060
+ modelId: hostedModel.id,
1055
1061
  });
1056
1062
 
1057
1063
  const parsed = await withAuth(
@@ -1113,6 +1119,7 @@ export const imageGenTool: CustomTool<typeof imageGenSchema, ImageGenToolDetails
1113
1119
  const prompt = assemblePrompt(params);
1114
1120
  const antigravityKey: ApiKey = ctx.modelRegistry.resolver("google-antigravity", {
1115
1121
  sessionId,
1122
+ modelId: DEFAULT_ANTIGRAVITY_MODEL,
1116
1123
  });
1117
1124
 
1118
1125
  const response = await withAuth(