@oh-my-pi/pi-coding-agent 16.5.1 → 16.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +3442 -3408
  3. package/dist/types/config/settings-schema.d.ts +10 -0
  4. package/dist/types/discovery/substitute-plugin-root.d.ts +22 -0
  5. package/dist/types/eval/backend.d.ts +3 -3
  6. package/dist/types/extensibility/extensions/wrapper.d.ts +3 -6
  7. package/dist/types/extensibility/plugins/bun-git-cache.d.ts +3 -0
  8. package/dist/types/goals/guided-setup.d.ts +12 -0
  9. package/dist/types/internal-urls/history-protocol.d.ts +3 -2
  10. package/dist/types/internal-urls/registry-helpers.d.ts +19 -0
  11. package/dist/types/mcp/oauth-discovery.d.ts +2 -0
  12. package/dist/types/mcp/oauth-flow.d.ts +2 -0
  13. package/dist/types/modes/components/__tests__/dynamic-border.test.d.ts +1 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +10 -0
  15. package/dist/types/modes/components/dynamic-border.d.ts +5 -3
  16. package/dist/types/modes/components/login-dialog.d.ts +2 -0
  17. package/dist/types/modes/components/mcp-add-wizard.d.ts +1 -0
  18. package/dist/types/modes/components/read-tool-group.d.ts +0 -2
  19. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  20. package/dist/types/modes/interactive-mode.d.ts +1 -0
  21. package/dist/types/modes/types.d.ts +1 -0
  22. package/dist/types/session/messages.d.ts +15 -0
  23. package/dist/types/tools/grep.d.ts +0 -2
  24. package/dist/types/tools/read.d.ts +0 -4
  25. package/package.json +12 -12
  26. package/src/advisor/__tests__/advisor.test.ts +136 -49
  27. package/src/advisor/runtime.ts +16 -34
  28. package/src/autoresearch/dashboard.ts +2 -2
  29. package/src/cli/config-cli.ts +15 -3
  30. package/src/config/settings-schema.ts +10 -0
  31. package/src/cursor.ts +2 -0
  32. package/src/discovery/claude-plugins.ts +9 -3
  33. package/src/discovery/omp-plugins.ts +6 -2
  34. package/src/discovery/substitute-plugin-root.ts +32 -0
  35. package/src/eval/__tests__/prelude-agent.test.ts +20 -0
  36. package/src/eval/backend.ts +3 -3
  37. package/src/eval/py/__tests__/prelude.test.ts +72 -0
  38. package/src/eval/py/prelude.py +28 -1
  39. package/src/exec/bash-executor.ts +30 -43
  40. package/src/extensibility/extensions/wrapper.ts +18 -18
  41. package/src/extensibility/plugins/bun-git-cache.ts +91 -0
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +32 -16
  43. package/src/extensibility/plugins/manager.ts +7 -7
  44. package/src/goals/guided-setup.ts +29 -1
  45. package/src/internal-urls/history-protocol.ts +95 -15
  46. package/src/internal-urls/registry-helpers.ts +50 -1
  47. package/src/launch/broker.ts +38 -25
  48. package/src/mcp/oauth-discovery.ts +20 -1
  49. package/src/mcp/oauth-flow.ts +3 -1
  50. package/src/modes/components/__tests__/dynamic-border.test.ts +55 -0
  51. package/src/modes/components/agent-dashboard.ts +2 -2
  52. package/src/modes/components/agent-hub.ts +15 -2
  53. package/src/modes/components/agent-transcript-viewer.ts +2 -2
  54. package/src/modes/components/chat-transcript-builder.ts +4 -3
  55. package/src/modes/components/dynamic-border.ts +9 -6
  56. package/src/modes/components/extensions/extension-list.ts +2 -2
  57. package/src/modes/components/hook-selector.ts +10 -4
  58. package/src/modes/components/login-dialog.ts +5 -0
  59. package/src/modes/components/mcp-add-wizard.ts +5 -0
  60. package/src/modes/components/plan-review-overlay.ts +11 -11
  61. package/src/modes/components/read-tool-group.ts +1 -8
  62. package/src/modes/controllers/input-controller.ts +4 -2
  63. package/src/modes/controllers/mcp-command-controller.ts +6 -7
  64. package/src/modes/controllers/selector-controller.ts +9 -2
  65. package/src/modes/controllers/todo-command-controller.ts +18 -14
  66. package/src/modes/interactive-mode.ts +7 -3
  67. package/src/modes/prompt-action-autocomplete.ts +6 -1
  68. package/src/modes/types.ts +1 -1
  69. package/src/prompts/system/system-prompt.md +1 -0
  70. package/src/prompts/tools/eval.md +2 -2
  71. package/src/prompts/tools/grep.md +1 -2
  72. package/src/prompts/tools/read.md +2 -4
  73. package/src/sdk.ts +28 -31
  74. package/src/session/agent-session.ts +44 -22
  75. package/src/session/messages.test.ts +66 -0
  76. package/src/session/messages.ts +37 -0
  77. package/src/system-prompt.test.ts +36 -0
  78. package/src/system-prompt.ts +1 -1
  79. package/src/tools/browser/registry.ts +17 -3
  80. package/src/tools/eval.ts +14 -9
  81. package/src/tools/gh.ts +3 -1
  82. package/src/tools/grep.ts +5 -45
  83. package/src/tools/path-utils.ts +7 -1
  84. package/src/tools/read.ts +23 -74
  85. package/src/utils/title-generator.ts +10 -6
  86. package/src/web/search/providers/perplexity-auth.ts +20 -11
  87. package/src/web/search/providers/perplexity.ts +14 -2
@@ -10,7 +10,7 @@ Fields:
10
10
  - `language` — {{#if py}}`"py"` IPython kernel{{/if}}{{#ifAll py js}}, {{/ifAll}}{{#if js}}`"js"` persistent JavaScript VM{{/if}}{{#if rb}}{{#ifAny py js}}, {{/ifAny}}`"rb"` persistent Ruby kernel{{/if}}{{#if jl}}{{#ifAny py js rb}}, {{/ifAny}}`"jl"` persistent Julia kernel{{/if}}.
11
11
  - `code` — cell body, verbatim. Newlines/quotes JSON-encoded; no fences, no headers.
12
12
  - `title` (optional) — short transcript label (e.g. `"imports"`).
13
- - `timeout` (optional) — seconds. Raise only for heavy compute or long non-agent tool calls.
13
+ - `timeout` (optional) — seconds; `0` disables the cell timeout. Raise only for heavy compute or long non-agent tool calls.
14
14
  - `reset` (optional) — wipe this language's kernel first.{{#ifAll py js}} Per-language: a `py` reset never touches the JS VM.{{/ifAll}}
15
15
 
16
16
  {{#if py}}Live event loop: use top-level `await` directly; `asyncio.run(…)` raises "cannot be called from a running event loop".{{/if}}
@@ -28,7 +28,7 @@ display(value) → None
28
28
  print(value, ...) → None
29
29
  Text output.
30
30
  read(path, offset?=1, limit?=None) → str
31
- File as text; offset/limit 1-indexed lines. Accepts `local://…`.
31
+ File/resource text; offset/limit = 1-indexed lines. `local://…` works everywhere; Python/JS also accept top-level `read` URI schemes.
32
32
  write(path, content) → str
33
33
  Write file (creates parents) → resolved path. `local://…` persists across turns/subagents.
34
34
  env(key?=None, value?=None) → str | None | dict
@@ -2,8 +2,7 @@ Greps files using regex.
2
2
 
3
3
  <instruction>
4
4
  - Supports Rust regex and PCRE2 syntax.
5
- - `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Use `selector` only for line-number filtering, never path/root selection (`"/"` belongs in `path`).
6
- - Literal colon filename + line range? Use `selector` (e.g. `{"path":"test:1-2","selector":"1-2"}`), not recursive `path:"test:1-2:1-2"`.
5
+ - `path`: SHOULD scope to a known path (e.g. `src`); pass several as a delimited list (`src; tests`). Append a line selector to one file path (e.g. `src/foo.ts:50-100`); selectors never choose the search root.
7
6
  - Cross-line patterns detected from literal `\n` or `\\n` in `pattern`.
8
7
  </instruction>
9
8
 
@@ -1,4 +1,4 @@
1
- Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via `path` plus optional `selector`.
1
+ Read files, directories, archives, SQLite, images, documents, internal resources, and web URLs via `path`.
2
2
 
3
3
  <instruction>
4
4
  - SHOULD parallelize independent reads.
@@ -7,8 +7,7 @@ Read files, directories, archives, SQLite, images, documents, internal resources
7
7
 
8
8
  ## Parameters
9
9
 
10
- - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`, `ssh://`), or URL. Inline `:<sel>` still works for ranges/modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
11
- - `selector` — optional selector without leading `:` (e.g. `"50-200"`, `"raw"`, `"raw:50-100"`, `"conflicts"`). Use when `path` contains literal colons: `{"path":"test:1-2","selector":"1-2"}`.
10
+ - `path` — required. Local path, internal URI (`skill://`, `agent://`, `artifact://`, `memory://`, `rule://`, `local://`, `vault://`, `mcp://`, `omp://`, `issue://`, `pr://`, `ssh://`), or URL. Append `:<sel>` for ranges/modes (e.g. `src/foo.ts:50-200`, `src/foo.ts:raw`, `db.sqlite:users:42`).
12
11
 
13
12
  ## Selectors
14
13
 
@@ -73,6 +72,5 @@ All URI schemes take the same line selectors. `artifact://<id>` recovers spilled
73
72
  `ssh://host/<absolute-path>` reads a remote text file (UTF-8, ≤1 MiB) or lists a directory one level deep, on a pre-configured SSH host or `~/.ssh/config` alias; `ssh://host/` lists the remote root and bare `ssh://` lists the configured hosts. Files are also writable via `write` and searchable via `search`; a directory only lists (`search` refuses a directory, `write` refuses to overwrite one). A literal `:`, `?`, or `#` in the remote path must be percent-encoded (`%3A`/`%3F`/`%23`) — a trailing `:sel` is read as a line selector, and `?`/`#` start a URL query/fragment. Requires a POSIX login shell (`sh`/`bash`/`zsh`); a Windows host or a non-POSIX shell (fish, csh/tcsh) is rejected — use the `ssh` tool there.
74
73
 
75
74
  <critical>
76
- - Literal colon filename + selector? Use `selector`, not recursive `path:"file:sel:sel"`.
77
75
  - Summary footer names elided ranges? Re-issue ONLY those ranges. NEVER guess `..`/`…` content.
78
76
  </critical>
package/src/sdk.ts CHANGED
@@ -117,6 +117,7 @@ import {
117
117
  type CustomMessage,
118
118
  convertToLlm,
119
119
  LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
120
+ replaceLlmImagesWithText,
120
121
  USER_INTERRUPT_LABEL,
121
122
  wrapSteeringForModel,
122
123
  } from "./session/messages";
@@ -1826,10 +1827,17 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
1826
1827
  // to mirror the AsyncJobManager ownership rule.
1827
1828
  if (mcpManager && !options.parentTaskPrefix) MCPManager.setInstance(mcpManager);
1828
1829
 
1829
- // Add image tools when the active model or configured image providers can generate images.
1830
- const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
1831
- if (imageGenTools.length > 0) {
1832
- customTools.push(...(imageGenTools as unknown as CustomTool[]));
1830
+ // Add image tools when generation is enabled and either no explicit tool
1831
+ // whitelist was given or it names `generate_image`. Unlike built-in tools
1832
+ // (filtered in `createTools`), custom tools are force-activated via
1833
+ // `alwaysInclude` below, so an explicit `--no-tools`/whitelist must be
1834
+ // honored here or image-gen would leak past every filter (issue #5305).
1835
+ const imageGenRequested = !options.toolNames || options.toolNames.includes("generate_image");
1836
+ if (settings.get("generate_image.enabled") && imageGenRequested) {
1837
+ const imageGenTools = await logger.time("getImageGenTools", () => getImageGenTools(modelRegistry, model));
1838
+ if (imageGenTools.length > 0) {
1839
+ customTools.push(...(imageGenTools as unknown as CustomTool[]));
1840
+ }
1833
1841
  }
1834
1842
 
1835
1843
  if (settings.get("speechgen.enabled")) {
@@ -2648,36 +2656,25 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2648
2656
 
2649
2657
  const slashCommands = await slashCommandsPromise;
2650
2658
 
2651
- // Create convertToLlm wrapper that filters images if blockImages is enabled (defense-in-depth)
2659
+ // Keep image blocks off the wire when they'd be rejected: either the user
2660
+ // disabled images (`images.blockImages`) or the active model has no vision
2661
+ // support. The latter covers switching from a vision model to a text-only
2662
+ // one mid-session — historical image blocks would otherwise be replayed to
2663
+ // a provider that 400s on them (#5400). Read both dynamically so a `/model`
2664
+ // switch or setting change takes effect on the next turn.
2652
2665
  const convertToLlmWithBlockImages = (messages: AgentMessage[]): Message[] => {
2653
2666
  const converted = convertToLlm(messages);
2654
- // Check setting dynamically so mid-session changes take effect
2655
- if (!settings.get("images.blockImages")) {
2656
- return converted;
2667
+ if (settings.get("images.blockImages")) {
2668
+ return replaceLlmImagesWithText(converted, "Image reading is disabled.");
2657
2669
  }
2658
- // Filter out ImageContent from all messages, replacing with text placeholder
2659
- return converted.map(msg => {
2660
- if (msg.role === "user" || msg.role === "toolResult") {
2661
- const content = msg.content;
2662
- if (Array.isArray(content)) {
2663
- const hasImages = content.some(c => c.type === "image");
2664
- if (hasImages) {
2665
- const filteredContent = content
2666
- .map(c =>
2667
- c.type === "image" ? { type: "text" as const, text: "Image reading is disabled." } : c,
2668
- )
2669
- .filter((c, i, arr) => {
2670
- // Dedupe consecutive "Image reading is disabled." texts
2671
- if (!(c.type === "text" && c.text === "Image reading is disabled." && i > 0)) return true;
2672
- const prev = arr[i - 1];
2673
- return !(prev.type === "text" && prev.text === "Image reading is disabled.");
2674
- });
2675
- return { ...msg, content: filteredContent };
2676
- }
2677
- }
2678
- }
2679
- return msg;
2680
- });
2670
+ const activeModel = agent?.state.model ?? model;
2671
+ if (activeModel && !activeModel.input.includes("image")) {
2672
+ return replaceLlmImagesWithText(
2673
+ converted,
2674
+ "[image omitted: the active model does not support image input]",
2675
+ );
2676
+ }
2677
+ return converted;
2681
2678
  };
2682
2679
 
2683
2680
  // Final convertToLlm: live provider replay drops API-level refusal errors,
@@ -10431,24 +10431,37 @@ export class AgentSession {
10431
10431
 
10432
10432
  // Strategy honored on manual /compact too. Custom instructions (public
10433
10433
  // user focus OR internal plan-mode guidance) imply a directed LLM
10434
- // summary; a text-only model cannot read snapcompact frames. When
10435
- // snapcompact itself was requested, fail locally instead of silently
10436
- // converting the "no LLM call" path into a provider-backed summary.
10434
+ // summary; a text-only model cannot read snapcompact frames.
10437
10435
  const wantsSnapcompact =
10438
10436
  compactionPrep.kind !== "fromHook" &&
10439
10437
  effectiveSettings.strategy === "snapcompact" &&
10440
10438
  !customInstructions &&
10441
10439
  !options?.internalGuidance;
10442
- const snapcompactReady = wantsSnapcompact;
10440
+ // `/compact snapcompact` is an explicit no-LLM archive request: honor
10441
+ // its contract by failing locally rather than silently shipping the
10442
+ // transcript to a provider. The default-configured snapcompact
10443
+ // strategy, in contrast, falls back to LLM compaction (mirroring the
10444
+ // auto-compaction path) so a routine /compact still completes on a
10445
+ // text-only model (issue #5064).
10446
+ const explicitSnapcompact = compactMode?.name === "snapcompact";
10447
+ let snapcompactReady = wantsSnapcompact;
10443
10448
  const snapcompactShapeSetting = this.settings.get("snapcompact.shape");
10444
10449
  let snapcompactShape: snapcompact.Shape | undefined;
10445
10450
  if (wantsSnapcompact && !this.model.input.includes("image")) {
10451
+ if (explicitSnapcompact) {
10452
+ this.emitNotice(
10453
+ "warning",
10454
+ `snapcompact needs a vision-capable model (${this.model.id} is text-only)`,
10455
+ "compaction",
10456
+ );
10457
+ throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
10458
+ }
10446
10459
  this.emitNotice(
10447
10460
  "warning",
10448
- `snapcompact needs a vision-capable model (${this.model.id} is text-only)`,
10461
+ `snapcompact needs a vision-capable model (${this.model.id} is text-only); falling back to LLM compaction`,
10449
10462
  "compaction",
10450
10463
  );
10451
- throw new Error(`snapcompact cannot run locally: ${this.model.id} is text-only.`);
10464
+ snapcompactReady = false;
10452
10465
  } else if (snapcompactReady) {
10453
10466
  const text = snapcompact.serializeConversation(
10454
10467
  convertToLlm(preparation.messagesToSummarize.concat(preparation.turnPrefixMessages)),
@@ -14037,19 +14050,23 @@ export class AgentSession {
14037
14050
  }
14038
14051
 
14039
14052
  /**
14040
- * Retry an empty, reason-less provider abort: a turn that ended `aborted`
14041
- * with no content and the generic sentinel (bare `abort()`), but only while
14042
- * the session is neither aborting nor tearing down. A user/lifecycle abort
14043
- * (`#abortInProgress`), a dispose-driven abort (`#isDisposed`), or a
14044
- * session-induced streaming-edit guard abort (`#streamingEditAbortTriggered` —
14045
- * auto-generated-file guard or failed-patch preview) is deliberate and MUST
14046
- * settle the turn instead: routing it through retry would orphan
14047
- * `#retryPromise` on a continuation the guard skips (hanging the in-flight
14048
- * `prompt()`) or silently undo the guard's intended abort.
14053
+ * Retry an empty, reason-less provider abort: a turn with no content that
14054
+ * carries the generic sentinel (bare `abort()`), whether the provider
14055
+ * finalized it as `stopReason: "aborted"` or leaked it as `stopReason:
14056
+ * "error"` (a stalled/dropped stream reported as an error rather than an
14057
+ * abort — issue #5375). Only fires while the session is neither aborting nor
14058
+ * tearing down. A user/lifecycle abort (`#abortInProgress`), a dispose-driven
14059
+ * abort (`#isDisposed`), or a session-induced streaming-edit guard abort
14060
+ * (`#streamingEditAbortTriggered` auto-generated-file guard or failed-patch
14061
+ * preview) is deliberate and MUST settle the turn instead: routing it through
14062
+ * retry would orphan `#retryPromise` on a continuation the guard skips
14063
+ * (hanging the in-flight `prompt()`) or silently undo the guard's intended
14064
+ * abort. Deliberate user interrupts (`UserInterrupt`) and silent aborts carry
14065
+ * their own marker, not the generic sentinel, so they never match here.
14049
14066
  */
14050
14067
  #isRetryableReasonlessAbort(message: AssistantMessage): boolean {
14051
14068
  if (
14052
- message.stopReason !== "aborted" ||
14069
+ (message.stopReason !== "aborted" && message.stopReason !== "error") ||
14053
14070
  message.content.length !== 0 ||
14054
14071
  this.#abortInProgress ||
14055
14072
  this.#isDisposed ||
@@ -14059,7 +14076,7 @@ export class AgentSession {
14059
14076
  }
14060
14077
 
14061
14078
  const id = this.#classifyRetryMessage(message);
14062
- if (AIError.is(id, AIError.Flag.Abort)) return true;
14079
+ if (message.stopReason === "aborted" && AIError.is(id, AIError.Flag.Abort)) return true;
14063
14080
  if (!this.#isGenericAbortSentinel(message)) return false;
14064
14081
 
14065
14082
  message.errorId = AIError.create(AIError.Flag.Abort);
@@ -16215,7 +16232,7 @@ export class AgentSession {
16215
16232
  // User message: leaf = parent (null if root), text goes to editor
16216
16233
  newLeafId = targetEntry.parentId;
16217
16234
  editorText = this.#extractUserMessageText(targetEntry.message.content);
16218
- } else if (targetEntry.type === "custom_message") {
16235
+ } else if (targetEntry.type === "custom_message" && targetEntry.customType !== SKILL_PROMPT_MESSAGE_TYPE) {
16219
16236
  // Custom message: leaf = parent (null if root), text goes to editor
16220
16237
  newLeafId = targetEntry.parentId;
16221
16238
  editorText =
@@ -16226,7 +16243,10 @@ export class AgentSession {
16226
16243
  .map(c => c.text)
16227
16244
  .join("");
16228
16245
  } else {
16229
- // Non-user message: leaf = selected node
16246
+ // Non-user message (or a user-invoked skill-prompt injection): land the
16247
+ // leaf on the selected node so it stays on the active branch. Skill
16248
+ // prompts are custom_message entries but must not be re-editable — their
16249
+ // content is a large expanded body, not a user turn (issue #5374).
16230
16250
  newLeafId = targetId;
16231
16251
  }
16232
16252
 
@@ -16579,10 +16599,12 @@ export class AgentSession {
16579
16599
  }
16580
16600
 
16581
16601
  #ingestProviderUsageHeaders(response: ProviderResponseMetadata, model?: Model): void {
16582
- if (model?.provider !== "anthropic") return;
16583
- this.#modelRegistry.authStorage.ingestUsageHeaders("anthropic", response.headers, {
16602
+ const provider = model?.provider;
16603
+ if (!provider) return;
16604
+ // No-op for providers whose usage strategy lacks a header parser.
16605
+ this.#modelRegistry.authStorage.ingestUsageHeaders(provider, response.headers, {
16584
16606
  sessionId: this.agent.sessionId,
16585
- baseUrl: this.#modelRegistry.getProviderBaseUrl?.("anthropic"),
16607
+ baseUrl: this.#modelRegistry.getProviderBaseUrl?.(provider),
16586
16608
  });
16587
16609
  }
16588
16610
 
@@ -5,6 +5,7 @@ import {
5
5
  type CustomMessage,
6
6
  convertToLlm,
7
7
  INTERRUPTED_THINKING_MESSAGE_TYPE,
8
+ replaceLlmImagesWithText,
8
9
  SKILL_PROMPT_MESSAGE_TYPE,
9
10
  type SkillPromptDetails,
10
11
  } from "./messages";
@@ -123,3 +124,68 @@ describe("convertToLlm", () => {
123
124
  ]);
124
125
  });
125
126
  });
127
+
128
+ describe("replaceLlmImagesWithText", () => {
129
+ it("replaces image blocks in user, developer, and tool-result messages with the placeholder", () => {
130
+ const converted = convertToLlm([
131
+ {
132
+ role: "user",
133
+ content: [
134
+ { type: "text", text: "look" },
135
+ { type: "image", data: "aaaa", mimeType: "image/png" },
136
+ ],
137
+ attribution: "user",
138
+ timestamp: 1,
139
+ },
140
+ {
141
+ role: "toolResult",
142
+ toolCallId: "c1",
143
+ toolName: "inspect_image",
144
+ content: [{ type: "image", data: "bbbb", mimeType: "image/png" }],
145
+ isError: false,
146
+ timestamp: 2,
147
+ },
148
+ ]);
149
+
150
+ const scrubbed = replaceLlmImagesWithText(converted, "[image omitted]");
151
+
152
+ expect(scrubbed).not.toBe(converted);
153
+ const types = scrubbed.flatMap(m => (Array.isArray(m.content) ? m.content.map(b => b.type) : []));
154
+ expect(types).not.toContain("image");
155
+ const user = scrubbed.find(m => m.role === "user");
156
+ expect(Array.isArray(user?.content) && user.content.map(b => (b.type === "text" ? b.text : b.type))).toEqual([
157
+ "look",
158
+ "[image omitted]",
159
+ ]);
160
+ const toolResult = scrubbed.find(m => m.role === "toolResult");
161
+ expect(Array.isArray(toolResult?.content) && toolResult.content).toEqual([
162
+ { type: "text", text: "[image omitted]" },
163
+ ]);
164
+ });
165
+
166
+ it("collapses consecutive image blocks into a single placeholder", () => {
167
+ const converted = convertToLlm([
168
+ {
169
+ role: "user",
170
+ content: [
171
+ { type: "image", data: "aaaa", mimeType: "image/png" },
172
+ { type: "image", data: "bbbb", mimeType: "image/png" },
173
+ ],
174
+ attribution: "user",
175
+ timestamp: 1,
176
+ },
177
+ ]);
178
+
179
+ const scrubbed = replaceLlmImagesWithText(converted, "[image omitted]");
180
+ const user = scrubbed.find(m => m.role === "user");
181
+ expect(Array.isArray(user?.content) && user.content).toEqual([{ type: "text", text: "[image omitted]" }]);
182
+ });
183
+
184
+ it("returns the same array reference when there are no image blocks", () => {
185
+ const converted = convertToLlm([
186
+ { role: "user", content: [{ type: "text", text: "hi" }], attribution: "user", timestamp: 1 },
187
+ ]);
188
+
189
+ expect(replaceLlmImagesWithText(converted, "[image omitted]")).toBe(converted);
190
+ });
191
+ });
@@ -512,6 +512,43 @@ export function stripImagesFromMessage(message: AgentMessage): number {
512
512
  }
513
513
  }
514
514
 
515
+ /**
516
+ * Replace every `ImageContent` block in already-converted LLM {@link Message}s
517
+ * with a text placeholder, returning a new array only when something changed.
518
+ *
519
+ * Unlike {@link stripImagesFromMessage} (which mutates persisted `AgentMessage`s
520
+ * in place), this operates on the ephemeral provider-request view produced by
521
+ * {@link convertToLlm}, so history on disk keeps its images while the outbound
522
+ * request is scrubbed. Used to keep image blocks off the wire when the active
523
+ * model has no vision support (or `images.blockImages` is set) — e.g. after
524
+ * switching from a vision model to a text-only one mid-session (#5400).
525
+ *
526
+ * Consecutive placeholder texts collapse into one so a message that was nothing
527
+ * but images does not balloon into a run of identical notes.
528
+ */
529
+ export function replaceLlmImagesWithText(messages: Message[], placeholder: string): Message[] {
530
+ let out: Message[] | undefined;
531
+ for (let i = 0; i < messages.length; i++) {
532
+ const msg = messages[i];
533
+ if (msg.role !== "user" && msg.role !== "developer" && msg.role !== "toolResult") continue;
534
+ const content = msg.content;
535
+ if (!Array.isArray(content) || !content.some(part => part.type === "image")) continue;
536
+ const replaced: (TextContent | ImageContent)[] = [];
537
+ for (const part of content) {
538
+ if (part.type !== "image") {
539
+ replaced.push(part);
540
+ continue;
541
+ }
542
+ const prev = replaced[replaced.length - 1];
543
+ if (prev?.type === "text" && prev.text === placeholder) continue;
544
+ replaced.push({ type: "text", text: placeholder });
545
+ }
546
+ if (out === undefined) out = messages.slice();
547
+ out[i] = { ...msg, content: replaced } as Message;
548
+ }
549
+ return out ?? messages;
550
+ }
551
+
515
552
  /**
516
553
  * Message type for bash executions via the ! command.
517
554
  */
@@ -198,3 +198,39 @@ describe.skipIf(process.platform !== "linux")("system prompt CPU model", () => {
198
198
  }
199
199
  });
200
200
  });
201
+
202
+ describe("non-Linux system prompt CPU model", () => {
203
+ it("includes the model returned by os.cpus", async () => {
204
+ const originalPlatform = process.platform;
205
+ Object.defineProperty(process, "platform", { value: "darwin" });
206
+ const cpus = spyOn(os, "cpus").mockImplementation(() => [
207
+ {
208
+ model: "Synthetic Non-Linux CPU",
209
+ speed: 0,
210
+ times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 },
211
+ },
212
+ ]);
213
+ try {
214
+ const systemPrompt = await buildSystemPrompt({
215
+ resolvedCustomPrompt: "Base prompt",
216
+ contextFiles: [],
217
+ skills: [],
218
+ rules: [],
219
+ workspaceTree: {
220
+ rootPath: import.meta.dir,
221
+ rendered: "",
222
+ truncated: false,
223
+ totalLines: 0,
224
+ agentsMdFiles: [],
225
+ },
226
+ activeRepoContext: null,
227
+ });
228
+
229
+ expect(cpus).toHaveBeenCalledTimes(1);
230
+ expect(systemPrompt.systemPrompt.join("\n")).toContain("- CPU: Synthetic Non-Linux CPU");
231
+ } finally {
232
+ cpus.mockRestore();
233
+ Object.defineProperty(process, "platform", { value: originalPlatform });
234
+ }
235
+ });
236
+ });
@@ -254,7 +254,7 @@ async function getCachedGpu(): Promise<string | undefined> {
254
254
  }
255
255
 
256
256
  async function getCpuModel(): Promise<string | undefined> {
257
- if (process.platform !== "linux") return undefined;
257
+ if (process.platform !== "linux") return os.cpus()[0]?.model;
258
258
  try {
259
259
  const cpuInfo = await Bun.file("/proc/cpuinfo").text();
260
260
  const match = /^model name\s*:\s*(.+)$/m.exec(cpuInfo);
@@ -1,5 +1,5 @@
1
1
  import * as path from "node:path";
2
- import { logger } from "@oh-my-pi/pi-utils";
2
+ import { logger, withTimeout } from "@oh-my-pi/pi-utils";
3
3
  import type { Subprocess } from "bun";
4
4
  import type { Browser, CDPSession } from "puppeteer-core";
5
5
  import { ToolAbortError, ToolError } from "../tool-errors";
@@ -17,6 +17,13 @@ export type BrowserKind = PuppeteerBrowserKind | CmuxKind;
17
17
 
18
18
  export type BrowserKindTag = BrowserKind["kind"];
19
19
 
20
+ /**
21
+ * Upper bound on `browser.close()` for headless Chromium. Puppeteer waits for
22
+ * the process to fully exit; a wedged Chromium would otherwise hang cleanup
23
+ * forever (issue #5260), so we cap the wait and force-kill on timeout.
24
+ */
25
+ const HEADLESS_CLOSE_TIMEOUT_MS = 5_000;
26
+
20
27
  interface BrowserHandleCommon {
21
28
  key: string;
22
29
  kind: BrowserKind;
@@ -232,10 +239,17 @@ async function disposeBrowserHandle(handle: BrowserHandle, opts: { kill: boolean
232
239
  }
233
240
  if (handle.kind.kind === "headless") {
234
241
  if (handle.browser.connected) {
242
+ // Puppeteer's `browser.close()` resolves only once the Chromium
243
+ // process fully exits. A wedged Chromium (a known Windows failure
244
+ // mode) leaves this await pending forever, freezing `releaseTab` in
245
+ // the "Closing tab" phase (issue #5260). Bound it, then SIGKILL the
246
+ // process tree so cleanup always completes.
247
+ const proc = handle.browser.process();
235
248
  try {
236
- await handle.browser.close();
249
+ await withTimeout(handle.browser.close(), HEADLESS_CLOSE_TIMEOUT_MS, "Timed out closing headless browser");
237
250
  } catch (err) {
238
- logger.debug("Failed to close headless browser", { error: (err as Error).message });
251
+ logger.debug("Failed to close headless browser; force-killing", { error: (err as Error).message });
252
+ if (proc?.pid !== undefined) await gracefulKillTreeOnce(proc.pid).catch(() => undefined);
239
253
  }
240
254
  }
241
255
  return;
package/src/tools/eval.ts CHANGED
@@ -85,7 +85,7 @@ function enabledEvalLanguages(backends: EvalBackendsAllowance): EvalLanguageToke
85
85
 
86
86
  const evalCellCommonFields = {
87
87
  "title?": type("string").describe('short label shown in transcript (e.g. "imports", "load config")'),
88
- "timeout?": type("number").describe("timeout for this eval call in seconds"),
88
+ "timeout?": type("number").describe("timeout for this eval call in seconds; 0 disables the cell timeout"),
89
89
  "reset?": type("boolean").describe("wipe this language's kernel before running. Other languages are untouched."),
90
90
  };
91
91
 
@@ -538,11 +538,16 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
538
538
  // ordinary tool calls all count against the budget. The watchdog drives
539
539
  // `combinedSignal`; we pass no wall-clock deadline downstream so the
540
540
  // backends never arm a competing fixed timer.
541
- const idleTimeoutMs = timeoutSecondsFromMs(cell.timeoutMs) * 1000;
542
- const idle = new IdleTimeout(idleTimeoutMs);
543
- const combinedSignal = signal
544
- ? AbortSignal.any([signal, idle.signal, sessionAbortController.signal])
545
- : AbortSignal.any([idle.signal, sessionAbortController.signal]);
541
+ const idleTimeoutMs = cell.timeoutMs === 0 ? undefined : timeoutSecondsFromMs(cell.timeoutMs) * 1000;
542
+ const idle = idleTimeoutMs === undefined ? undefined : new IdleTimeout(idleTimeoutMs);
543
+ const combinedSignal =
544
+ signal && idle
545
+ ? AbortSignal.any([signal, idle.signal, sessionAbortController.signal])
546
+ : signal
547
+ ? AbortSignal.any([signal, sessionAbortController.signal])
548
+ : idle
549
+ ? AbortSignal.any([idle.signal, sessionAbortController.signal])
550
+ : sessionAbortController.signal;
546
551
 
547
552
  const cellResult = cellResults[i];
548
553
  cellResult.status = "running";
@@ -570,11 +575,11 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
570
575
  },
571
576
  onStatus: event => {
572
577
  if (event.op === EVAL_TIMEOUT_PAUSE_OP) {
573
- idle.pause();
578
+ idle?.pause();
574
579
  return;
575
580
  }
576
581
  if (event.op === EVAL_TIMEOUT_RESUME_OP) {
577
- idle.resume();
582
+ idle?.resume();
578
583
  return;
579
584
  }
580
585
  cellResult.statusEvents ??= [];
@@ -583,7 +588,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
583
588
  },
584
589
  });
585
590
  } finally {
586
- idle.dispose();
591
+ idle?.dispose();
587
592
  activeLiveCell = undefined;
588
593
  }
589
594
  const durationMs = Date.now() - startTime;
package/src/tools/gh.ts CHANGED
@@ -3419,7 +3419,9 @@ async function executeSearchCode(
3419
3419
  signal: AbortSignal | undefined,
3420
3420
  ): Promise<AgentToolResult<GhToolDetails>> {
3421
3421
  const query = requireNonEmpty(params.query, "query");
3422
- if (params.since !== undefined || params.until !== undefined) {
3422
+ const since = normalizeOptionalString(params.since);
3423
+ const until = normalizeOptionalString(params.until);
3424
+ if (since !== undefined || until !== undefined) {
3423
3425
  throw new ToolError("search_code does not support since/until; GitHub code search has no date qualifier.");
3424
3426
  }
3425
3427
  const limit = resolveSearchLimit(params.limit);