@oh-my-pi/pi-coding-agent 15.5.6 → 15.5.8

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 (76) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/dist/types/cli/auth-gateway-cli.d.ts +8 -0
  3. package/dist/types/commands/auth-gateway.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +60 -12
  5. package/dist/types/edit/file-snapshot-store.d.ts +9 -6
  6. package/dist/types/edit/hashline/diff.d.ts +4 -5
  7. package/dist/types/edit/streaming.d.ts +2 -1
  8. package/dist/types/eval/py/index.d.ts +1 -0
  9. package/dist/types/extensibility/custom-tools/types.d.ts +1 -1
  10. package/dist/types/extensibility/shared-events.d.ts +1 -1
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/vault-protocol.d.ts +93 -0
  13. package/dist/types/lib/xai-http.d.ts +40 -0
  14. package/dist/types/mcp/transports/http.d.ts +9 -0
  15. package/dist/types/modes/components/tool-execution.d.ts +2 -1
  16. package/dist/types/session/agent-session.d.ts +4 -1
  17. package/dist/types/tools/fetch.d.ts +16 -0
  18. package/dist/types/tools/image-gen.d.ts +6 -2
  19. package/dist/types/tools/index.d.ts +1 -0
  20. package/dist/types/tools/match-line-format.d.ts +2 -2
  21. package/dist/types/tools/plan-mode-guard.d.ts +5 -6
  22. package/dist/types/tools/render-utils.d.ts +3 -1
  23. package/dist/types/tools/tts.d.ts +18 -0
  24. package/dist/types/tools/write.d.ts +2 -0
  25. package/dist/types/utils/file-mentions.d.ts +2 -0
  26. package/package.json +8 -8
  27. package/src/cli/args.ts +2 -0
  28. package/src/cli/auth-broker-cli.ts +2 -1
  29. package/src/cli/auth-gateway-cli.ts +210 -9
  30. package/src/commands/auth-gateway.ts +7 -1
  31. package/src/config/model-registry.ts +41 -9
  32. package/src/config/settings-schema.ts +55 -13
  33. package/src/edit/file-snapshot-store.ts +9 -6
  34. package/src/edit/hashline/diff.ts +26 -13
  35. package/src/edit/hashline/execute.ts +13 -9
  36. package/src/edit/renderer.ts +9 -9
  37. package/src/edit/streaming.ts +4 -6
  38. package/src/eval/py/index.ts +1 -1
  39. package/src/extensibility/custom-tools/types.ts +1 -1
  40. package/src/extensibility/shared-events.ts +1 -1
  41. package/src/internal-urls/docs-index.generated.ts +7 -7
  42. package/src/internal-urls/index.ts +1 -0
  43. package/src/internal-urls/router.ts +2 -0
  44. package/src/internal-urls/vault-protocol.ts +936 -0
  45. package/src/lib/xai-http.ts +124 -0
  46. package/src/main.ts +1 -2
  47. package/src/mcp/transports/http.ts +29 -2
  48. package/src/modes/components/tool-execution.ts +6 -4
  49. package/src/modes/controllers/event-controller.ts +10 -3
  50. package/src/modes/controllers/selector-controller.ts +7 -2
  51. package/src/modes/interactive-mode.ts +11 -3
  52. package/src/modes/utils/ui-helpers.ts +2 -1
  53. package/src/prompts/system/system-prompt.md +3 -0
  54. package/src/prompts/tools/ast-edit.md +1 -1
  55. package/src/prompts/tools/ast-grep.md +1 -1
  56. package/src/prompts/tools/read.md +3 -3
  57. package/src/prompts/tools/search.md +1 -1
  58. package/src/sdk.ts +41 -10
  59. package/src/session/agent-session.ts +112 -14
  60. package/src/system-prompt.ts +2 -0
  61. package/src/tools/ast-edit.ts +10 -7
  62. package/src/tools/ast-grep.ts +12 -11
  63. package/src/tools/eval.ts +28 -3
  64. package/src/tools/fetch.ts +52 -24
  65. package/src/tools/image-gen.ts +205 -7
  66. package/src/tools/index.ts +1 -0
  67. package/src/tools/match-line-format.ts +2 -2
  68. package/src/tools/path-utils.ts +2 -0
  69. package/src/tools/plan-mode-guard.ts +20 -7
  70. package/src/tools/read.ts +70 -55
  71. package/src/tools/render-utils.ts +15 -0
  72. package/src/tools/search.ts +14 -14
  73. package/src/tools/tts.ts +133 -0
  74. package/src/tools/write.ts +61 -6
  75. package/src/utils/file-mentions.ts +11 -5
  76. package/src/web/search/providers/codex.ts +2 -1
@@ -18,6 +18,7 @@ import * as fs from "node:fs";
18
18
  import * as path from "node:path";
19
19
  import { scheduler } from "node:timers/promises";
20
20
  import { isPromise } from "node:util/types";
21
+ import type { InMemorySnapshotStore } from "@oh-my-pi/hashline";
21
22
  import {
22
23
  type AfterToolCallContext,
23
24
  type AfterToolCallResult,
@@ -104,6 +105,8 @@ import { onAppendOnlyModeChanged } from "../config/settings";
104
105
  import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
105
106
  import { loadCapability } from "../discovery";
106
107
  import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
108
+ import { getFileSnapshotStore } from "../edit/file-snapshot-store";
109
+ import { namespaceSessionId as namespacePythonSessionId } from "../eval/py";
107
110
  import {
108
111
  disposeKernelSessionsByOwner,
109
112
  executePython as executePythonCommand,
@@ -209,7 +212,11 @@ import { YieldQueue } from "./yield-queue";
209
212
  /** Session-specific events that extend the core AgentEvent */
210
213
  export type AgentSessionEvent =
211
214
  | AgentEvent
212
- | { type: "auto_compaction_start"; reason: "threshold" | "overflow" | "idle"; action: "context-full" | "handoff" }
215
+ | {
216
+ type: "auto_compaction_start";
217
+ reason: "threshold" | "overflow" | "idle" | "incomplete";
218
+ action: "context-full" | "handoff";
219
+ }
213
220
  | {
214
221
  type: "auto_compaction_end";
215
222
  action: "context-full" | "handoff";
@@ -738,6 +745,7 @@ export class AgentSession {
738
745
  readonly sessionManager: SessionManager;
739
746
  readonly settings: Settings;
740
747
  readonly yieldQueue: YieldQueue;
748
+ fileSnapshotStore?: InMemorySnapshotStore;
741
749
 
742
750
  #powerAssertion: MacOSPowerAssertion | undefined;
743
751
 
@@ -3631,9 +3639,17 @@ export class AgentSession {
3631
3639
  const sessionOnResponse = this.#onResponse;
3632
3640
  const sessionMetadata = this.agent.metadataForProvider(provider);
3633
3641
  const sessionOnSseEvent = this.#onSseEvent;
3634
- if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent) return options;
3635
-
3636
- const preparedOptions: SimpleStreamOptions = { ...options };
3642
+ const openrouterRoutingPreset =
3643
+ provider === "openrouter" ? this.settings.get("providers.openrouterVariant") : "default";
3644
+ const openrouterVariant =
3645
+ openrouterRoutingPreset !== "default" && options.openrouterVariant === undefined
3646
+ ? openrouterRoutingPreset
3647
+ : undefined;
3648
+ if (!sessionOnPayload && !sessionOnResponse && !sessionMetadata && !sessionOnSseEvent && !openrouterVariant)
3649
+ return options;
3650
+
3651
+ const preparedOptions: SimpleStreamOptions =
3652
+ openrouterVariant === undefined ? { ...options } : { ...options, openrouterVariant };
3637
3653
 
3638
3654
  // Stamp session metadata (e.g. user_id={session_id}) onto direct-call requests so
3639
3655
  // they share the same session bucket as Agent.prompt-routed requests on Anthropic
@@ -3758,6 +3774,10 @@ export class AgentSession {
3758
3774
  this.#planReferencePath = path;
3759
3775
  }
3760
3776
 
3777
+ getPlanReferencePath(): string {
3778
+ return this.#planReferencePath;
3779
+ }
3780
+
3761
3781
  get clientBridge(): ClientBridge | undefined {
3762
3782
  return this.#clientBridge;
3763
3783
  }
@@ -4144,6 +4164,7 @@ export class AgentSession {
4144
4164
  const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
4145
4165
  autoResizeImages: this.settings.get("images.autoResize"),
4146
4166
  useHashLines: resolveFileDisplayMode(this).hashLines,
4167
+ snapshotStore: getFileSnapshotStore(this),
4147
4168
  });
4148
4169
  messages.push(...fileMentionMessages);
4149
4170
  }
@@ -5575,6 +5596,11 @@ export class AgentSession {
5575
5596
  initiatorOverride: "agent",
5576
5597
  metadata: this.agent.metadataForProvider(model.provider),
5577
5598
  telemetry: resolveTelemetry(this.agent.telemetry, this.sessionId),
5599
+ // Honor the user's /model thinking selection on the handoff
5600
+ // path. Clamped per-model inside generateHandoff via
5601
+ // resolveCompactionEffort so unsupported-effort models don't
5602
+ // trip requireSupportedEffort.
5603
+ thinkingLevel: this.thinkingLevel,
5578
5604
  },
5579
5605
  handoffSignal,
5580
5606
  );
@@ -5645,10 +5671,14 @@ export class AgentSession {
5645
5671
  * Check if context maintenance or promotion is needed and run it.
5646
5672
  * Called after agent_end and before prompt submission.
5647
5673
  *
5648
- * Three cases (in order):
5649
- * 1. Overflow + promotion: promote to larger model, retry without maintenance
5650
- * 2. Overflow + no promotion target: run context maintenance, auto-retry on same model
5651
- * 3. Threshold: Context over threshold, run context maintenance (no auto-retry)
5674
+ * Four cases (in order):
5675
+ * 1. Input overflow + promotion: promote to larger model, retry without maintenance.
5676
+ * 2. Input overflow + no promotion target: run context maintenance, auto-retry on same model.
5677
+ * 3. Output incomplete (stopReason === "length", e.g. `response.incomplete`): the
5678
+ * model burned its output budget without producing an actionable deliverable
5679
+ * (reasoning-only or truncated). Drop the dead turn, try promotion, otherwise
5680
+ * run compaction/handoff and retry.
5681
+ * 4. Threshold: context over threshold, run context maintenance (no auto-retry).
5652
5682
  *
5653
5683
  * @param assistantMessage The assistant message to check
5654
5684
  * @param skipAbortedCheck If false, include aborted messages (for pre-prompt check). Default: true
@@ -5707,10 +5737,49 @@ export class AgentSession {
5707
5737
  }
5708
5738
  return false;
5709
5739
  }
5740
+
5741
+ // Case 3: Output-side incomplete — `response.incomplete` from OpenAI Responses
5742
+ // (and Codex) maps to stopReason === "length". The model burned its
5743
+ // `max_output_tokens` budget on reasoning/text and emitted no actionable
5744
+ // deliverable. Same recovery class as overflow: promotion if available,
5745
+ // otherwise compaction/handoff. Unlike overflow, the *input* is fine, so we
5746
+ // allow the handoff strategy to actually run.
5747
+ if (sameModel && !errorIsFromBeforeCompaction && assistantMessage.stopReason === "length") {
5748
+ const messages = this.agent.state.messages;
5749
+ if (messages.length > 0 && messages[messages.length - 1].role === "assistant") {
5750
+ this.agent.replaceMessages(messages.slice(0, -1));
5751
+ }
5752
+
5753
+ const promoted = await this.#tryContextPromotion(assistantMessage);
5754
+ if (promoted) {
5755
+ logger.debug("Context promotion triggered by response.incomplete (length stop)", {
5756
+ from: `${assistantMessage.provider}/${assistantMessage.model}`,
5757
+ });
5758
+ this.#scheduleAgentContinue({ delayMs: 100, generation });
5759
+ return false;
5760
+ }
5761
+
5762
+ const incompleteCompactionSettings = this.settings.getGroup("compaction");
5763
+ if (incompleteCompactionSettings.enabled && incompleteCompactionSettings.strategy !== "off") {
5764
+ logger.debug("Compaction triggered by response.incomplete (length stop, no promotion target)", {
5765
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
5766
+ strategy: incompleteCompactionSettings.strategy,
5767
+ });
5768
+ await this.#runAutoCompaction("incomplete", true, false, allowDefer);
5769
+ } else {
5770
+ // Neither promotion nor compaction is available — surface the dead-end so
5771
+ // the user understands why the turn yielded with nothing.
5772
+ logger.warn("response.incomplete with no recovery path (promotion + compaction both unavailable)", {
5773
+ model: `${assistantMessage.provider}/${assistantMessage.model}`,
5774
+ });
5775
+ }
5776
+ return false;
5777
+ }
5778
+
5710
5779
  const compactionSettings = this.settings.getGroup("compaction");
5711
5780
  if (!compactionSettings.enabled || compactionSettings.strategy === "off") return false;
5712
5781
 
5713
- // Case 2: Threshold - turn succeeded but context is getting large
5782
+ // Case 4: Threshold - turn succeeded but context is getting large
5714
5783
  // Skip if this was an error (non-overflow errors don't have usage data)
5715
5784
  if (assistantMessage.stopReason === "error") return false;
5716
5785
  const pruneResult = await this.#pruneToolOutputs();
@@ -6345,6 +6414,11 @@ export class AgentSession {
6345
6414
  metadata: this.agent.metadataForProvider(candidate.provider),
6346
6415
  convertToLlm,
6347
6416
  telemetry,
6417
+ // Honor the user's /model thinking selection (incl. `off`) on
6418
+ // the manual `/compact` path. Clamped per-model inside compact()
6419
+ // via resolveCompactionEffort so unsupported-effort models
6420
+ // (xai-oauth/grok-build) don't trip requireSupportedEffort.
6421
+ thinkingLevel: this.thinkingLevel,
6348
6422
  });
6349
6423
  } catch (error) {
6350
6424
  if (!this.#isCompactionAuthFailure(error)) {
@@ -6428,7 +6502,7 @@ export class AgentSession {
6428
6502
  * @returns true when a deferred handoff was scheduled. Inline runs always return false.
6429
6503
  */
6430
6504
  async #runAutoCompaction(
6431
- reason: "overflow" | "threshold" | "idle",
6505
+ reason: "overflow" | "threshold" | "idle" | "incomplete",
6432
6506
  willRetry: boolean,
6433
6507
  deferred = false,
6434
6508
  allowDefer = true,
@@ -6437,10 +6511,14 @@ export class AgentSession {
6437
6511
  if (compactionSettings.strategy === "off") return false;
6438
6512
  if (reason !== "idle" && !compactionSettings.enabled) return false;
6439
6513
  const generation = this.#promptGeneration;
6514
+ // "overflow" and "incomplete" force inline execution because they are recovery
6515
+ // paths the caller wants resolved before scheduling the next turn. "idle" is
6516
+ // triggered by the idle loop and does its own scheduling.
6440
6517
  if (
6441
6518
  !deferred &&
6442
6519
  allowDefer &&
6443
6520
  reason !== "overflow" &&
6521
+ reason !== "incomplete" &&
6444
6522
  reason !== "idle" &&
6445
6523
  compactionSettings.strategy === "handoff"
6446
6524
  ) {
@@ -6455,6 +6533,9 @@ export class AgentSession {
6455
6533
  return true;
6456
6534
  }
6457
6535
 
6536
+ // "overflow" forces context-full because the input itself is broken — a handoff
6537
+ // LLM call would hit the same overflow. "incomplete" is an output-side problem,
6538
+ // so a handoff request on the existing context is still viable.
6458
6539
  let action: "context-full" | "handoff" =
6459
6540
  compactionSettings.strategy === "handoff" && reason !== "overflow" ? "handoff" : "context-full";
6460
6541
  await this.#emitSessionEvent({ type: "auto_compaction_start", reason, action });
@@ -6617,6 +6698,11 @@ export class AgentSession {
6617
6698
  initiatorOverride: "agent",
6618
6699
  convertToLlm,
6619
6700
  telemetry,
6701
+ // Honor the user's /model thinking selection on the
6702
+ // auto-compaction path — the most-fired compaction
6703
+ // site. Clamped per-model inside compact() via
6704
+ // resolveCompactionEffort.
6705
+ thinkingLevel: this.thinkingLevel,
6620
6706
  });
6621
6707
  break;
6622
6708
  } catch (error) {
@@ -6750,8 +6836,18 @@ export class AgentSession {
6750
6836
  if (willRetry) {
6751
6837
  const messages = this.agent.state.messages;
6752
6838
  const lastMsg = messages[messages.length - 1];
6753
- if (lastMsg?.role === "assistant" && (lastMsg as AssistantMessage).stopReason === "error") {
6754
- this.agent.replaceMessages(messages.slice(0, -1));
6839
+ if (lastMsg?.role === "assistant") {
6840
+ const lastAssistant = lastMsg as AssistantMessage;
6841
+ // Drop the prior turn before retry when it carries no actionable deliverable:
6842
+ // - "error": failure was kept in history but must not re-enter the next turn's prompt.
6843
+ // - reason === "incomplete" && stopReason === "length": truncated output (typically
6844
+ // reasoning-only) — re-running it produces the same dead-end.
6845
+ const shouldDrop =
6846
+ lastAssistant.stopReason === "error" ||
6847
+ (reason === "incomplete" && lastAssistant.stopReason === "length");
6848
+ if (shouldDrop) {
6849
+ this.agent.replaceMessages(messages.slice(0, -1));
6850
+ }
6755
6851
  }
6756
6852
 
6757
6853
  this.#scheduleAgentContinue({ delayMs: 100, generation });
@@ -6785,7 +6881,9 @@ export class AgentSession {
6785
6881
  errorMessage:
6786
6882
  reason === "overflow"
6787
6883
  ? `Context overflow recovery failed: ${errorMessage}`
6788
- : `Auto-compaction failed: ${errorMessage}`,
6884
+ : reason === "incomplete"
6885
+ ? `Incomplete response recovery failed: ${errorMessage}`
6886
+ : `Auto-compaction failed: ${errorMessage}`,
6789
6887
  });
6790
6888
  } finally {
6791
6889
  if (this.#autoCompactionAbortController === autoCompactionAbortController) {
@@ -7494,7 +7592,7 @@ export class AgentSession {
7494
7592
  });
7495
7593
  const result = await executePythonCommand(code, {
7496
7594
  cwd,
7497
- sessionId,
7595
+ sessionId: namespacePythonSessionId(sessionId),
7498
7596
  kernelOwnerId: this.#evalKernelOwnerId,
7499
7597
  kernelMode: this.settings.get("python.kernelMode"),
7500
7598
  onChunk,
@@ -11,6 +11,7 @@ import { systemPromptCapability } from "./capability/system-prompt";
11
11
  import type { SkillsSettings } from "./config/settings";
12
12
  import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
13
13
  import { loadSkills, type Skill } from "./extensibility/skills";
14
+ import { hasObsidian } from "./internal-urls/vault-protocol";
14
15
  import customSystemPromptTemplate from "./prompts/system/custom-system-prompt.md" with { type: "text" };
15
16
  import projectPromptTemplate from "./prompts/system/project-prompt.md" with { type: "text" };
16
17
  import systemPromptTemplate from "./prompts/system/system-prompt.md" with { type: "text" };
@@ -569,6 +570,7 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
569
570
  mcpDiscoveryServerSummaries,
570
571
  eagerTasks,
571
572
  secretsEnabled,
573
+ hasObsidian: hasObsidian(),
572
574
  };
573
575
  const rendered = prompt.render(resolvedCustomPrompt ? customSystemPromptTemplate : systemPromptTemplate, data);
574
576
  const systemPrompt = [rendered];
@@ -1,11 +1,13 @@
1
1
  import * as path from "node:path";
2
- import { computeFileHash, formatHashlineHeader } from "@oh-my-pi/hashline";
2
+ import { formatHashlineHeader } from "@oh-my-pi/hashline";
3
3
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
4
  import { type AstReplaceChange, type AstReplaceFileChange, astEdit } 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
7
  import { $envpos, prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import * as z from "zod/v4";
9
+ import { getFileSnapshotStore } from "../edit/file-snapshot-store";
10
+ import { normalizeToLF } from "../edit/normalize";
9
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
10
12
  import type { Theme } from "../modes/theme/theme";
11
13
  import astEditDescription from "../prompts/tools/ast-edit.md" with { type: "text" };
@@ -281,14 +283,15 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
281
283
  }
282
284
 
283
285
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
284
- const hashContexts = new Map<string, { fileHash: string }>();
286
+ const hashContexts = new Map<string, { tag: string }>();
285
287
  if (useHashLines) {
288
+ const snapshotStore = getFileSnapshotStore(this.session);
286
289
  for (const relativePath of fileList) {
287
290
  const absolutePath = path.resolve(this.session.cwd, relativePath);
288
291
  try {
289
- const fullText = await Bun.file(absolutePath).text();
290
- const fileHash = computeFileHash(fullText);
291
- hashContexts.set(relativePath, { fileHash });
292
+ const fullText = normalizeToLF(await Bun.file(absolutePath).text());
293
+ const tag = snapshotStore.recordContiguous(absolutePath, 1, fullText.split("\n"), { fullText });
294
+ hashContexts.set(relativePath, { tag });
292
295
  } catch {
293
296
  // Best-effort: if a file disappears between ast-edit and rendering, emit plain line output.
294
297
  }
@@ -326,7 +329,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
326
329
  const rendered = renderChangesForFile(relativePath);
327
330
  const count = fileReplacementCounts.get(relativePath) ?? 0;
328
331
  const hashContext = hashContexts.get(relativePath);
329
- const hashSuffix = hashContext ? `#${hashContext.fileHash}` : "";
332
+ const hashSuffix = hashContext ? `#${hashContext.tag}` : "";
330
333
  return {
331
334
  headerSuffix: `${hashSuffix} (${formatCount("replacement", count)})`,
332
335
  modelLines: rendered.model,
@@ -346,7 +349,7 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
346
349
  }
347
350
  const hashContext = hashContexts.get(relativePath);
348
351
  if (hashContext) {
349
- outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash));
352
+ outputLines.push(formatHashlineHeader(relativePath, hashContext.tag));
350
353
  }
351
354
  outputLines.push(...rendered.model);
352
355
  displayLines.push(...rendered.display);
@@ -1,5 +1,7 @@
1
+ import { constants } from "node:fs";
2
+ import { access } from "node:fs/promises";
1
3
  import * as path from "node:path";
2
- import { computeFileHash, formatHashlineHeader } from "@oh-my-pi/hashline";
4
+ import { formatHashlineHeader } from "@oh-my-pi/hashline";
3
5
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
4
6
  import { type AstFindMatch, astGrep } from "@oh-my-pi/pi-natives";
5
7
  import type { Component } from "@oh-my-pi/pi-tui";
@@ -219,14 +221,14 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
219
221
  }
220
222
 
221
223
  const useHashLines = resolveFileDisplayMode(this.session).hashLines;
222
- const hashContexts = new Map<string, { absolutePath: string; fileHash: string }>();
224
+ const hashContexts = new Map<string, { absolutePath: string; tag?: string }>();
225
+ const snapshotStore = useHashLines ? getFileSnapshotStore(this.session) : undefined;
223
226
  if (useHashLines) {
224
227
  for (const relativePath of fileList) {
225
228
  const absolutePath = path.resolve(this.session.cwd, relativePath);
226
229
  try {
227
- const fullText = await Bun.file(absolutePath).text();
228
- const fileHash = computeFileHash(fullText);
229
- hashContexts.set(relativePath, { absolutePath, fileHash });
230
+ await access(absolutePath, constants.R_OK);
231
+ hashContexts.set(relativePath, { absolutePath });
230
232
  } catch {
231
233
  // Best-effort: if a file disappears between ast-grep and rendering, emit plain line output.
232
234
  }
@@ -268,9 +270,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
268
270
  fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
269
271
  }
270
272
  if (hashContext && cacheEntries.length > 0) {
271
- getFileSnapshotStore(this.session).recordSparse(hashContext.absolutePath, cacheEntries, {
272
- fileHash: hashContext.fileHash,
273
- });
273
+ const tag = snapshotStore?.recordSparse(hashContext.absolutePath, cacheEntries);
274
+ if (tag) hashContext.tag = tag;
274
275
  }
275
276
  return { model: modelOut, display: displayOut };
276
277
  };
@@ -282,7 +283,7 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
282
283
  return {
283
284
  modelLines: rendered.model,
284
285
  displayLines: rendered.display,
285
- headerSuffix: hashContext ? `#${hashContext.fileHash}` : "",
286
+ headerSuffix: hashContext?.tag ? `#${hashContext.tag}` : "",
286
287
  skip: rendered.model.length === 0,
287
288
  };
288
289
  });
@@ -297,8 +298,8 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
297
298
  displayLines.push("");
298
299
  }
299
300
  const hashContext = hashContexts.get(relativePath);
300
- if (hashContext) {
301
- outputLines.push(formatHashlineHeader(relativePath, hashContext.fileHash));
301
+ if (hashContext?.tag) {
302
+ outputLines.push(formatHashlineHeader(relativePath, hashContext.tag));
302
303
  }
303
304
  outputLines.push(...rendered.model);
304
305
  displayLines.push(...rendered.display);
package/src/tools/eval.ts CHANGED
@@ -14,6 +14,7 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
14
14
  import evalDescription from "../prompts/tools/eval.md" with { type: "text" };
15
15
  import { DEFAULT_MAX_BYTES, OutputSink, type OutputSummary, TailBuffer } from "../session/streaming-output";
16
16
  import { getTreeBranch, getTreeContinuePrefix, renderCodeCell } from "../tui";
17
+ import { formatDimensionNote, resizeImage } from "../utils/image-resize";
17
18
  import { resolveEvalBackends, type ToolSession } from ".";
18
19
  import { truncateForPrompt } from "./approval";
19
20
  import {
@@ -403,6 +404,7 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
403
404
 
404
405
  const cellStatusEvents: EvalStatusEvent[] = [];
405
406
  const cellDisplayOutputs: EvalDisplayOutput[] = [];
407
+ const cellImageNotes: string[] = [];
406
408
  let cellHasMarkdown = false;
407
409
  for (const output of result.displayOutputs) {
408
410
  if (output.type === "json") {
@@ -410,8 +412,26 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
410
412
  cellDisplayOutputs.push(output);
411
413
  }
412
414
  if (output.type === "image") {
413
- images.push({ type: "image", data: output.data, mimeType: output.mimeType });
414
- cellDisplayOutputs.push(output);
415
+ const resized = await resizeImage({
416
+ type: "image",
417
+ data: output.data,
418
+ mimeType: output.mimeType,
419
+ });
420
+ const image: ImageContent = {
421
+ type: "image",
422
+ data: resized.data,
423
+ mimeType: resized.mimeType,
424
+ };
425
+ images.push(image);
426
+ cellDisplayOutputs.push({
427
+ type: "image",
428
+ data: image.data,
429
+ mimeType: image.mimeType,
430
+ });
431
+ const dimensionNote = formatDimensionNote(resized);
432
+ if (dimensionNote) {
433
+ cellImageNotes.push(`display image ${cellImageNotes.length + 1}: ${dimensionNote}`);
434
+ }
415
435
  }
416
436
  if (output.type === "status") {
417
437
  statusEvents.push(output.event);
@@ -423,9 +443,14 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
423
443
  }
424
444
 
425
445
  const stdoutTrimmed = result.output.trim();
446
+ const imageText = cellImageNotes.join("\n");
426
447
  const displayText = formatDisplayOutputsForText(cellDisplayOutputs);
448
+ const visibleDisplayText =
449
+ displayText && imageText ? `${displayText}\n\n${imageText}` : displayText || imageText;
427
450
  const cellOutput =
428
- stdoutTrimmed && displayText ? `${stdoutTrimmed}\n\n${displayText}` : stdoutTrimmed || displayText;
451
+ stdoutTrimmed && visibleDisplayText
452
+ ? `${stdoutTrimmed}\n\n${visibleDisplayText}`
453
+ : stdoutTrimmed || visibleDisplayText;
429
454
  cellResult.output = cellOutput;
430
455
  cellResult.exitCode = result.exitCode;
431
456
  cellResult.durationMs = durationMs;
@@ -562,9 +562,22 @@ function parseFeedToMarkdown(content: string, maxItems = 10): string {
562
562
  }
563
563
 
564
564
  /**
565
- * Render HTML to markdown using Parallel, jina, trafilatura, lynx (in order of preference)
565
+ * Cap on any single remote reader-mode request (Parallel, Jina) so a stalled
566
+ * remote endpoint cannot consume the whole reader-mode budget and starve the
567
+ * local fallback renderers (trafilatura, lynx, native). See #1449.
566
568
  */
567
- async function renderHtmlToText(
569
+ const REMOTE_READER_MAX_MS = 10_000;
570
+
571
+ /**
572
+ * Render HTML to markdown using Parallel, jina, trafilatura, lynx, then the
573
+ * in-process native converter. The overall `timeout` budget bounds the call,
574
+ * but remote reader requests are additionally capped at `REMOTE_READER_MAX_MS`
575
+ * so that a hung remote endpoint cannot prevent local fallbacks from running.
576
+ * Only a real `userSignal` cancellation aborts the chain — remote per-attempt
577
+ * timeouts and the overall reader-mode timeout still allow later renderers
578
+ * (especially the purely-local native converter) to be tried.
579
+ */
580
+ export async function renderHtmlToText(
568
581
  url: string,
569
582
  html: string,
570
583
  timeout: number,
@@ -572,14 +585,15 @@ async function renderHtmlToText(
572
585
  userSignal: AbortSignal | undefined,
573
586
  storage: AgentStorage | null,
574
587
  ): Promise<{ content: string; ok: boolean; method: string }> {
575
- const signal = ptree.combineSignals(userSignal, timeout * 1000);
588
+ const overallSignal = ptree.combineSignals(userSignal, timeout * 1000);
576
589
  const execOptions = {
577
590
  mode: "group" as const,
578
591
  allowNonZero: true,
579
592
  allowAbort: true,
580
593
  stderr: "full" as const,
581
- signal,
594
+ signal: overallSignal,
582
595
  };
596
+ const remoteBudgetMs = Math.min(timeout * 1000, REMOTE_READER_MAX_MS);
583
597
 
584
598
  // Try Parallel extract first when credentials are configured
585
599
  if (settings.get("providers.parallelFetch") && findParallelApiKey(storage)) {
@@ -590,7 +604,7 @@ async function renderHtmlToText(
590
604
  objective: "Extract the main content",
591
605
  excerpts: true,
592
606
  fullContent: false,
593
- signal,
607
+ signal: ptree.combineSignals(userSignal, remoteBudgetMs),
594
608
  },
595
609
  storage,
596
610
  );
@@ -602,17 +616,18 @@ async function renderHtmlToText(
602
616
  }
603
617
  }
604
618
  } catch {
605
- // Parallel extract failed, continue to next method
606
- signal?.throwIfAborted();
619
+ // Parallel extract failed or stalled; honour real cancellation only.
620
+ userSignal?.throwIfAborted();
607
621
  }
608
622
  }
609
623
 
610
- // Try jina first (reader API)
624
+ // Try jina reader API with its own sub-budget so a stall cannot starve
625
+ // later fallbacks (#1449).
611
626
  try {
612
627
  const jinaUrl = `https://r.jina.ai/${url}`;
613
628
  const response = await fetch(jinaUrl, {
614
629
  headers: { Accept: "text/markdown" },
615
- signal,
630
+ signal: ptree.combineSignals(userSignal, remoteBudgetMs),
616
631
  });
617
632
  if (response.ok) {
618
633
  const content = await response.text();
@@ -621,37 +636,50 @@ async function renderHtmlToText(
621
636
  }
622
637
  }
623
638
  } catch {
624
- // Jina failed, continue to next method
625
- signal?.throwIfAborted();
639
+ // Jina failed or stalled; honour real cancellation only.
640
+ userSignal?.throwIfAborted();
626
641
  }
627
642
 
628
643
  // Try trafilatura (auto-install via uv/pip)
629
- const trafilatura = await ensureTool("trafilatura", { signal, silent: true });
630
- if (trafilatura) {
631
- const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
632
- if (result.ok && result.stdout.trim().length > 100) {
633
- return { content: result.stdout, ok: true, method: "trafilatura" };
644
+ try {
645
+ const trafilatura = await ensureTool("trafilatura", { signal: overallSignal, silent: true });
646
+ if (trafilatura) {
647
+ const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
648
+ if (result.ok && result.stdout.trim().length > 100) {
649
+ return { content: result.stdout, ok: true, method: "trafilatura" };
650
+ }
634
651
  }
652
+ } catch {
653
+ // trafilatura unavailable or stalled; continue to next method.
654
+ userSignal?.throwIfAborted();
635
655
  }
636
656
 
637
657
  // Try lynx (can't auto-install, system package)
638
- const lynx = hasCommand("lynx");
639
- if (lynx) {
640
- const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
641
- if (result.ok) {
642
- return { content: result.stdout, ok: true, method: "lynx" };
658
+ try {
659
+ const lynx = hasCommand("lynx");
660
+ if (lynx) {
661
+ const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
662
+ if (result.ok) {
663
+ return { content: result.stdout, ok: true, method: "lynx" };
664
+ }
643
665
  }
666
+ } catch {
667
+ // lynx failed or stalled; continue to native converter.
668
+ userSignal?.throwIfAborted();
644
669
  }
645
670
 
646
- // Fall back to native converter (fastest, no network/subprocess)
671
+ // Fall back to native converter (purely local, no network/subprocess).
672
+ // Always attempted: even if remote renderers and subprocesses were aborted
673
+ // by the overall reader-mode timeout, this still works on already-loaded
674
+ // HTML (#1449).
647
675
  try {
648
676
  const content = await htmlToMarkdown(html, { cleanContent: true });
649
677
  if (content.trim().length > 100 && !isLowQualityOutput(content)) {
650
678
  return { content, ok: true, method: "native" };
651
679
  }
652
680
  } catch {
653
- // Native converter failed, continue to next method
654
- signal?.throwIfAborted();
681
+ // Native converter failed; nothing else to try.
682
+ userSignal?.throwIfAborted();
655
683
  }
656
684
  return { content: "", ok: false, method: "none" };
657
685
  }