@oh-my-pi/pi-coding-agent 16.2.13 → 16.3.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 (166) hide show
  1. package/CHANGELOG.md +108 -7
  2. package/dist/cli.js +6033 -5982
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/settings-schema.d.ts +28 -5
  9. package/dist/types/edit/modes/patch.d.ts +11 -0
  10. package/dist/types/eval/agent-bridge.d.ts +5 -1
  11. package/dist/types/exec/bash-executor.d.ts +1 -0
  12. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  13. package/dist/types/extensibility/skills.d.ts +2 -1
  14. package/dist/types/mnemopi/state.d.ts +12 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  16. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  17. package/dist/types/modes/components/usage-row.d.ts +1 -1
  18. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  20. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  21. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  22. package/dist/types/modes/interactive-mode.d.ts +12 -7
  23. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  25. package/dist/types/modes/session-teardown.d.ts +63 -0
  26. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  27. package/dist/types/modes/theme/theme.d.ts +9 -4
  28. package/dist/types/modes/types.d.ts +2 -3
  29. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  30. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +9 -0
  32. package/dist/types/session/agent-session.d.ts +46 -13
  33. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  34. package/dist/types/session/session-loader.d.ts +5 -0
  35. package/dist/types/task/executor.d.ts +12 -5
  36. package/dist/types/task/parallel.d.ts +1 -0
  37. package/dist/types/task/render.test.d.ts +1 -0
  38. package/dist/types/thinking.d.ts +2 -0
  39. package/dist/types/tools/checkpoint.d.ts +8 -0
  40. package/dist/types/tools/eval-render.d.ts +1 -0
  41. package/dist/types/tools/fetch.d.ts +11 -1
  42. package/dist/types/tools/index.d.ts +3 -1
  43. package/dist/types/tools/irc.d.ts +1 -0
  44. package/dist/types/tools/output-meta.d.ts +7 -0
  45. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  46. package/dist/types/tools/path-utils.d.ts +16 -0
  47. package/dist/types/tools/render-utils.d.ts +4 -1
  48. package/dist/types/utils/git.d.ts +47 -2
  49. package/dist/types/web/search/provider.d.ts +7 -0
  50. package/dist/types/web/search/types.d.ts +1 -1
  51. package/package.json +12 -12
  52. package/src/advisor/config.ts +4 -2
  53. package/src/cli/bench-cli.ts +7 -2
  54. package/src/collab/guest.ts +94 -5
  55. package/src/collab/host.ts +80 -3
  56. package/src/collab/protocol.ts +9 -2
  57. package/src/commit/model-selection.ts +8 -2
  58. package/src/config/keybindings.ts +3 -1
  59. package/src/config/model-discovery.ts +66 -2
  60. package/src/config/model-registry.ts +7 -3
  61. package/src/config/model-resolver.ts +51 -23
  62. package/src/config/settings-schema.ts +44 -14
  63. package/src/edit/hashline/diff.ts +13 -2
  64. package/src/edit/index.ts +58 -8
  65. package/src/edit/modes/patch.ts +53 -18
  66. package/src/edit/streaming.ts +7 -6
  67. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  68. package/src/eval/agent-bridge.ts +15 -5
  69. package/src/exec/bash-executor.ts +7 -12
  70. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  71. package/src/extensibility/skills.ts +29 -6
  72. package/src/goals/guided-setup.ts +4 -3
  73. package/src/internal-urls/docs-index.generated.txt +1 -1
  74. package/src/lsp/client.ts +11 -14
  75. package/src/main.ts +38 -10
  76. package/src/mnemopi/state.ts +43 -7
  77. package/src/modes/acp/acp-agent.ts +1 -1
  78. package/src/modes/components/agent-hub.ts +10 -2
  79. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  80. package/src/modes/components/assistant-message.ts +16 -10
  81. package/src/modes/components/chat-transcript-builder.ts +11 -1
  82. package/src/modes/components/custom-editor.test.ts +20 -0
  83. package/src/modes/components/hook-selector.ts +44 -25
  84. package/src/modes/components/model-selector.ts +1 -1
  85. package/src/modes/components/status-line/component.test.ts +44 -0
  86. package/src/modes/components/status-line/component.ts +9 -1
  87. package/src/modes/components/status-line/segments.ts +3 -3
  88. package/src/modes/components/usage-row.ts +16 -2
  89. package/src/modes/controllers/command-controller.ts +6 -21
  90. package/src/modes/controllers/event-controller.ts +11 -9
  91. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  92. package/src/modes/controllers/input-controller.ts +16 -13
  93. package/src/modes/controllers/selector-controller.ts +3 -8
  94. package/src/modes/controllers/streaming-reveal.ts +75 -53
  95. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  96. package/src/modes/interactive-mode.ts +122 -79
  97. package/src/modes/rpc/rpc-client.ts +21 -0
  98. package/src/modes/rpc/rpc-mode.ts +197 -46
  99. package/src/modes/session-teardown.test.ts +219 -0
  100. package/src/modes/session-teardown.ts +82 -0
  101. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  102. package/src/modes/skill-command.ts +7 -20
  103. package/src/modes/theme/theme.ts +29 -15
  104. package/src/modes/types.ts +2 -3
  105. package/src/modes/utils/copy-targets.ts +12 -0
  106. package/src/modes/utils/ui-helpers.ts +19 -2
  107. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  108. package/src/prompts/advisor/system.md +1 -1
  109. package/src/prompts/agents/tester.md +6 -2
  110. package/src/prompts/skills/autoload.md +8 -0
  111. package/src/prompts/skills/user-invocation.md +11 -0
  112. package/src/prompts/steering/parent-irc.md +5 -0
  113. package/src/prompts/system/irc-incoming.md +2 -0
  114. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  115. package/src/prompts/system/plan-mode-approved.md +7 -10
  116. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  117. package/src/prompts/system/plan-mode-reference.md +3 -4
  118. package/src/prompts/system/rewind-report.md +6 -0
  119. package/src/prompts/system/subagent-system-prompt.md +3 -0
  120. package/src/prompts/tools/irc.md +2 -1
  121. package/src/prompts/tools/job.md +2 -2
  122. package/src/prompts/tools/rewind.md +3 -2
  123. package/src/prompts/tools/task.md +4 -0
  124. package/src/sdk.ts +18 -4
  125. package/src/session/agent-session.ts +660 -114
  126. package/src/session/exit-diagnostics.ts +202 -0
  127. package/src/session/session-context.ts +25 -13
  128. package/src/session/session-loader.ts +58 -25
  129. package/src/session/session-manager.ts +0 -1
  130. package/src/session/session-persistence.ts +30 -4
  131. package/src/session/settings-stream-fn.ts +14 -0
  132. package/src/slash-commands/builtin-registry.ts +35 -3
  133. package/src/system-prompt.ts +15 -1
  134. package/src/task/executor.ts +31 -8
  135. package/src/task/index.ts +31 -9
  136. package/src/task/isolation-runner.ts +18 -2
  137. package/src/task/parallel.ts +7 -2
  138. package/src/task/render.test.ts +121 -0
  139. package/src/task/render.ts +48 -2
  140. package/src/task/worktree.ts +12 -13
  141. package/src/task/yield-assembly.ts +8 -5
  142. package/src/thinking.ts +5 -0
  143. package/src/tools/ask.ts +188 -9
  144. package/src/tools/ast-edit.ts +7 -0
  145. package/src/tools/ast-grep.ts +11 -0
  146. package/src/tools/bash.ts +6 -30
  147. package/src/tools/checkpoint.ts +15 -1
  148. package/src/tools/eval-render.ts +15 -0
  149. package/src/tools/fetch.ts +82 -18
  150. package/src/tools/gh.ts +1 -1
  151. package/src/tools/grep.ts +45 -27
  152. package/src/tools/index.ts +3 -1
  153. package/src/tools/irc.ts +24 -9
  154. package/src/tools/output-meta.ts +50 -0
  155. package/src/tools/output-schema-validator.ts +152 -15
  156. package/src/tools/path-utils.ts +55 -10
  157. package/src/tools/read.ts +1 -1
  158. package/src/tools/render-utils.ts +5 -3
  159. package/src/tools/yield.ts +41 -1
  160. package/src/utils/commit-message-generator.ts +2 -2
  161. package/src/utils/git.ts +271 -29
  162. package/src/utils/thinking-display.ts +13 -0
  163. package/src/web/search/index.ts +9 -28
  164. package/src/web/search/provider.ts +26 -1
  165. package/src/web/search/providers/duckduckgo.ts +1 -1
  166. package/src/web/search/types.ts +5 -1
@@ -16,6 +16,7 @@ import { Ellipsis, fileHyperlink, framedBlock, renderStatusLine, truncateToWidth
16
16
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
17
17
  import type { ToolSession } from ".";
18
18
  import { truncateForPrompt } from "./approval";
19
+ import { parseReadUrlTarget } from "./fetch";
19
20
  import { createFileRecorder, formatResultPath } from "./file-recorder";
20
21
  import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
21
22
  import type { OutputMeta } from "./output-meta";
@@ -284,6 +285,12 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
284
285
  signal,
285
286
  localProtocolOptions: this.session.localProtocolOptions,
286
287
  skills: this.session.skills,
288
+ resolveExternalUrl: async rawPath => {
289
+ if (!parseReadUrlTarget(rawPath)) return undefined;
290
+ throw new ToolError(
291
+ `Cannot rewrite external URL: ${rawPath}. Use \`read\` or \`search\` to inspect fetched web content; ast_edit only applies to local files.`,
292
+ );
293
+ },
287
294
  });
288
295
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
289
296
 
@@ -14,6 +14,7 @@ import astGrepDescription from "../prompts/tools/ast-grep.md" with { type: "text
14
14
  import { Ellipsis, fileHyperlink, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
15
15
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
16
16
  import type { ToolSession } from ".";
17
+ import { materializeReadUrlToFile, parseReadUrlTarget } from "./fetch";
17
18
  import { createFileRecorder, formatResultPath } from "./file-recorder";
18
19
  import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
19
20
  import { formatMatchLine } from "./match-line-format";
@@ -208,6 +209,16 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
208
209
  signal,
209
210
  localProtocolOptions: this.session.localProtocolOptions,
210
211
  skills: this.session.skills,
212
+ resolveExternalUrl: async rawPath => {
213
+ const target = parseReadUrlTarget(rawPath);
214
+ if (!target) return undefined;
215
+ const materialized = await materializeReadUrlToFile(
216
+ this.session,
217
+ { path: target.path, raw: target.raw },
218
+ signal,
219
+ );
220
+ return { sourcePath: materialized.path, immutable: true };
221
+ },
211
222
  });
212
223
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
213
224
 
package/src/tools/bash.ts CHANGED
@@ -29,7 +29,12 @@ import { checkBashInterception } from "./bash-interceptor";
29
29
  import { canUseInteractiveBashPty } from "./bash-pty-selection";
30
30
  import { expandInternalUrls, type InternalUrlExpansionOptions } from "./bash-skill-urls";
31
31
  import { invalidateGithubCacheForBashCommand } from "./gh-cache-invalidation";
32
- import { formatStyledTruncationWarning, type OutputMeta, stripOutputNotice } from "./output-meta";
32
+ import {
33
+ formatStyledTruncationWarning,
34
+ type OutputMeta,
35
+ stripOutputNotice,
36
+ stripRawOutputArtifactNotice,
37
+ } from "./output-meta";
33
38
  import { resolveToCwd } from "./path-utils";
34
39
  import { capPreviewLines, formatToolWorkingDirectory, previewWindowRows, replaceTabs } from "./render-utils";
35
40
  import { ToolAbortError, ToolError } from "./tool-errors";
@@ -292,35 +297,6 @@ function formatExitCodeNotice(exitCode: number): string {
292
297
  return `Command exited with code ${exitCode}`;
293
298
  }
294
299
 
295
- const RAW_OUTPUT_ARTIFACT_PREFIX = "[raw output: artifact://";
296
- const RAW_OUTPUT_ARTIFACT_SUFFIX = "]";
297
-
298
- function stripRawOutputArtifactNotice(text: string): { text: string; artifactId?: string } {
299
- const trimmed = text.trimEnd();
300
- const lineStart = trimmed.lastIndexOf("\n");
301
- const candidateStart = lineStart === -1 ? 0 : lineStart + 1;
302
- if (
303
- !trimmed.startsWith(RAW_OUTPUT_ARTIFACT_PREFIX, candidateStart) ||
304
- !trimmed.endsWith(RAW_OUTPUT_ARTIFACT_SUFFIX)
305
- ) {
306
- return { text };
307
- }
308
-
309
- const idStart = candidateStart + RAW_OUTPUT_ARTIFACT_PREFIX.length;
310
- const idEnd = trimmed.length - RAW_OUTPUT_ARTIFACT_SUFFIX.length;
311
- if (idStart === idEnd) return { text };
312
- for (let i = idStart; i < idEnd; i++) {
313
- const code = trimmed.charCodeAt(i);
314
- if (code < 48 || code > 57) return { text };
315
- }
316
-
317
- const artifactId = trimmed.slice(idStart, idEnd);
318
- return {
319
- text: trimmed.slice(0, lineStart === -1 ? 0 : lineStart).trimEnd(),
320
- artifactId,
321
- };
322
- }
323
-
324
300
  /**
325
301
  * Strip the trailing occurrence of `notice` (plus a single surrounding newline
326
302
  * on each side) so the TUI can echo the value via a styled footer label
@@ -17,6 +17,15 @@ export interface CheckpointState {
17
17
  startedAt: string;
18
18
  }
19
19
 
20
+ export interface CompletedRewindState {
21
+ /** Report retained after a successful rewind. */
22
+ report: string;
23
+ /** Timestamp for the checkpoint that was rewound. */
24
+ startedAt: string;
25
+ /** Timestamp when the rewind completed. */
26
+ rewoundAt: string;
27
+ }
28
+
20
29
  const checkpointSchema = type({
21
30
  goal: type("string").describe("investigation goal"),
22
31
  });
@@ -123,7 +132,12 @@ export class RewindTool implements AgentTool<typeof rewindSchema, RewindToolDeta
123
132
  throw new ToolError("Checkpoint not available in subagents.");
124
133
  }
125
134
  if (!this.session.getCheckpointState?.()) {
126
- throw new ToolError("No active checkpoint.");
135
+ if (this.session.getLastCompletedRewind?.()) {
136
+ throw new ToolError(
137
+ "Checkpoint already completed; continue from the retained rewind report instead of calling rewind again.",
138
+ );
139
+ }
140
+ throw new ToolError("No active checkpoint. Create a checkpoint before calling rewind.");
127
141
  }
128
142
  const report = params.report.trim();
129
143
  if (report.length === 0) {
@@ -775,4 +775,19 @@ export const evalToolRenderer = {
775
775
  // below the first cell. Expanded output is top-anchored enough for the
776
776
  // transcript to commit its settled prefix.
777
777
  provisionalPendingPreview: "collapsed",
778
+ // Partial-result chrome is NOT byte-stable: `renderAgentProgressEvents`
779
+ // inserts/removes each subagent's current-tool line as it starts/stops a
780
+ // tool, and ticks status icon/stats/duration on already-rendered rows,
781
+ // while `options.isPartial` holds for the whole eval() cell (agent
782
+ // progress ticks never carry an `async` completed/failed state, so
783
+ // `event-controller.ts` keeps `isPartial: true` throughout). If this
784
+ // block were commit-stable during that churn, `deriveLiveCommitState`'s
785
+ // stable-prefix ratchet would promote agent rows that keep mutating (a
786
+ // "slow ticker", see transcript-container.ts) into native scrollback,
787
+ // and the tui resync's "duplication, never loss" contract would
788
+ // repeatedly re-show the frame tail under a heavy concurrent
789
+ // `agent()`/`parallel()` fan-out — the overlapping/duplicated tree rows
790
+ // seen when many subagents run at once. Once the cell settles
791
+ // (`isPartial === false`) the block is commit-stable again.
792
+ provisionalPartialResult: true,
778
793
  };
@@ -27,7 +27,7 @@ import { finalizeOutput, loadPage, looksLikeHtml, MAX_BYTES, MAX_OUTPUT_CHARS }
27
27
  import { convertWithMarkit, fetchBinary } from "../web/scrapers/utils";
28
28
  import { applyListLimit } from "./list-limit";
29
29
  import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
30
- import { type LineRange, parseLineRanges } from "./path-utils";
30
+ import { isReadableUrlPath, type LineRange, parseLineRanges } from "./path-utils";
31
31
  import { formatBytes, formatExpandHint, getDomain, replaceTabs } from "./render-utils";
32
32
  import { listTables, looksLikeSqlite, renderTableList } from "./sqlite-reader";
33
33
  import { ToolAbortError, ToolError } from "./tool-errors";
@@ -145,10 +145,6 @@ function normalizeUrl(url: string): string {
145
145
  return url;
146
146
  }
147
147
 
148
- export function isReadableUrlPath(value: string): boolean {
149
- return /^https?:\/\/?/i.test(value) || /^www\./i.test(value);
150
- }
151
-
152
148
  // URL line selectors mirror the file form: `:50`, `:50-100`, `:50+150`, `:5-10,20-30`, `:raw`,
153
149
  // or `:raw:N-M` / `:N-M:raw` to combine raw mode with a range. If a URL would otherwise look
154
150
  // like `host:port`, add a trailing slash before the selector (e.g. `https://example.com/:80`
@@ -1559,9 +1555,12 @@ export interface ReadUrlToolDetails {
1559
1555
 
1560
1556
  interface ReadUrlCacheEntry {
1561
1557
  artifactId?: string;
1558
+ artifactPath?: string;
1559
+ contentPath?: string;
1562
1560
  details: ReadUrlToolDetails;
1563
1561
  image?: FetchImagePayload;
1564
1562
  output: string;
1563
+ content: string;
1565
1564
  }
1566
1565
 
1567
1566
  const READ_URL_CACHE_MAX_ENTRIES = 100;
@@ -1572,20 +1571,24 @@ function getReadUrlCacheKey(session: ToolSession, requestedUrl: string, raw: boo
1572
1571
  return `${scope}::${raw ? "raw" : "rendered"}::${normalizeUrl(requestedUrl)}`;
1573
1572
  }
1574
1573
 
1575
- async function readArtifactOutput(session: ToolSession, artifactId: string): Promise<string | null> {
1574
+ async function findArtifactPath(session: ToolSession, artifactId: string): Promise<string | null> {
1576
1575
  const artifactsDir = session.getArtifactsDir?.();
1577
1576
  if (!artifactsDir) return null;
1578
1577
 
1579
1578
  try {
1580
1579
  const files = await fs.readdir(artifactsDir);
1581
1580
  const match = files.find(file => file.startsWith(`${artifactId}.`));
1582
- if (!match) return null;
1583
- return await Bun.file(path.join(artifactsDir, match)).text();
1581
+ return match ? path.join(artifactsDir, match) : null;
1584
1582
  } catch {
1585
1583
  return null;
1586
1584
  }
1587
1585
  }
1588
1586
 
1587
+ async function readArtifactOutput(session: ToolSession, artifactId: string): Promise<string | null> {
1588
+ const artifactPath = await findArtifactPath(session, artifactId);
1589
+ return artifactPath ? await Bun.file(artifactPath).text() : null;
1590
+ }
1591
+
1589
1592
  async function materializeReadUrlCacheEntry(
1590
1593
  session: ToolSession,
1591
1594
  entry: ReadUrlCacheEntry,
@@ -1600,17 +1603,58 @@ async function materializeReadUrlCacheEntry(
1600
1603
  return entry.output.length > 0 ? entry : null;
1601
1604
  }
1602
1605
 
1603
- async function persistReadUrlArtifact(session: ToolSession, output: string): Promise<string | undefined> {
1604
- const { path: artifactPath, id } = (await session.allocateOutputArtifact?.("read")) ?? {};
1605
- if (!artifactPath) return undefined;
1606
- await Bun.write(artifactPath, output);
1607
- return id;
1606
+ async function persistReadUrlArtifact(
1607
+ session: ToolSession,
1608
+ output: string,
1609
+ ): Promise<{ id?: string; path?: string } | undefined> {
1610
+ const artifact = await session.allocateOutputArtifact?.("read");
1611
+ if (!artifact?.path) return undefined;
1612
+ await Bun.write(artifact.path, output);
1613
+ return artifact;
1608
1614
  }
1609
1615
 
1610
1616
  async function ensureReadUrlCacheArtifact(session: ToolSession, entry: ReadUrlCacheEntry): Promise<ReadUrlCacheEntry> {
1611
- if (entry.artifactId) return entry;
1612
- const artifactId = await persistReadUrlArtifact(session, entry.output);
1613
- return artifactId ? { ...entry, artifactId } : entry;
1617
+ if (entry.artifactId && entry.artifactPath) return entry;
1618
+ if (entry.artifactId) {
1619
+ const artifactPath = await findArtifactPath(session, entry.artifactId);
1620
+ if (artifactPath) return { ...entry, artifactPath };
1621
+ }
1622
+ const artifact = await persistReadUrlArtifact(session, entry.output);
1623
+ return artifact?.id ? { ...entry, artifactId: artifact.id, artifactPath: artifact.path } : entry;
1624
+ }
1625
+
1626
+ function readUrlContentExtension(finalUrl: string): string {
1627
+ try {
1628
+ const ext = getFilenameExtensionHint(new URL(finalUrl).pathname);
1629
+ return ext && /^\.[a-z0-9][a-z0-9+.-]{0,15}$/i.test(ext) ? ext : ".txt";
1630
+ } catch {
1631
+ return ".txt";
1632
+ }
1633
+ }
1634
+
1635
+ async function ensureReadUrlContentFile(
1636
+ session: ToolSession,
1637
+ entry: ReadUrlCacheEntry,
1638
+ raw: boolean,
1639
+ ): Promise<ReadUrlCacheEntry> {
1640
+ if (entry.contentPath) {
1641
+ try {
1642
+ await Bun.file(entry.contentPath).stat();
1643
+ return entry;
1644
+ } catch {
1645
+ // Recreate below when the cached scratch file was removed.
1646
+ }
1647
+ }
1648
+ const root = session.getArtifactsDir?.();
1649
+ if (!root) {
1650
+ throw new ToolError("Cannot search URL output because this session cannot materialize read artifacts.");
1651
+ }
1652
+ const dir = path.join(root, "url-search");
1653
+ await fs.mkdir(dir, { recursive: true });
1654
+ const hash = Bun.hash(`${raw ? "raw" : "rendered"}:${entry.details.finalUrl}`).toString(36);
1655
+ const contentPath = path.join(dir, `${hash}${readUrlContentExtension(entry.details.finalUrl)}`);
1656
+ await Bun.write(contentPath, entry.content);
1657
+ return { ...entry, contentPath };
1614
1658
  }
1615
1659
 
1616
1660
  function cacheReadUrlEntry(session: ToolSession, requestedUrl: string, raw: boolean, entry: ReadUrlCacheEntry): void {
@@ -1644,10 +1688,11 @@ async function buildReadUrlCacheEntry(
1644
1688
  webpExclusionForModel(session.getActiveModel?.()),
1645
1689
  );
1646
1690
  const output = buildUrlReadOutput(result, result.content);
1647
- const artifactId = options?.ensureArtifact ? await persistReadUrlArtifact(session, output) : undefined;
1691
+ const artifact = options?.ensureArtifact ? await persistReadUrlArtifact(session, output) : undefined;
1648
1692
 
1649
1693
  return {
1650
- artifactId,
1694
+ artifactId: artifact?.id,
1695
+ artifactPath: artifact?.path,
1651
1696
  details: {
1652
1697
  kind: "url",
1653
1698
  url: result.url,
@@ -1659,6 +1704,7 @@ async function buildReadUrlCacheEntry(
1659
1704
  },
1660
1705
  image: result.image,
1661
1706
  output,
1707
+ content: result.content,
1662
1708
  };
1663
1709
  }
1664
1710
 
@@ -1686,6 +1732,24 @@ export async function loadReadUrlCacheEntry(
1686
1732
  return fresh;
1687
1733
  }
1688
1734
 
1735
+ /** Materialize rendered URL body text to a local file for tools that require filesystem paths. */
1736
+ export async function materializeReadUrlToFile(
1737
+ session: ToolSession,
1738
+ params: { path: string; raw?: boolean },
1739
+ signal?: AbortSignal,
1740
+ ): Promise<{ path: string; details: ReadUrlToolDetails }> {
1741
+ if (!session.settings.get("fetch.enabled")) {
1742
+ throw new ToolError("URL reads are disabled by settings.");
1743
+ }
1744
+ const cacheEntry = await loadReadUrlCacheEntry(session, params, signal, { preferCached: true });
1745
+ const materialized = await ensureReadUrlContentFile(session, cacheEntry, params.raw ?? false);
1746
+ cacheReadUrlEntry(session, params.path, params.raw ?? false, materialized);
1747
+ if (!materialized.contentPath) {
1748
+ throw new ToolError("Cannot search URL output because this session cannot materialize read artifacts.");
1749
+ }
1750
+ return { path: materialized.contentPath, details: materialized.details };
1751
+ }
1752
+
1689
1753
  function buildUrlReadOutput(result: FetchRenderResult, content: string): string {
1690
1754
  let output = "";
1691
1755
  output += `URL: ${result.finalUrl}\n`;
package/src/tools/gh.ts CHANGED
@@ -3112,7 +3112,7 @@ async function checkoutPullRequest(
3112
3112
  remote.name,
3113
3113
  `refs/heads/${headRefName}`,
3114
3114
  `refs/remotes/${remote.name}/${headRefName}`,
3115
- signal,
3115
+ { signal },
3116
3116
  );
3117
3117
 
3118
3118
  if (!existingWorktree) {
package/src/tools/grep.ts CHANGED
@@ -36,6 +36,7 @@ import {
36
36
  import { resolveFileDisplayMode } from "../utils/file-display-mode";
37
37
  import { type ArchiveReader, type ExtractedArchiveFile, openArchive, parseArchivePathCandidates } from "../utils/zip";
38
38
  import type { ToolSession } from ".";
39
+ import { materializeReadUrlToFile, parseReadUrlTarget } from "./fetch";
39
40
  import { createFileRecorder, formatResultPath } from "./file-recorder";
40
41
  import { classifyGroupedLines, formatGroupedFiles, groupLineIndicesByBlank } from "./grouped-file-output";
41
42
  import { formatMatchLine } from "./match-line-format";
@@ -901,6 +902,18 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
901
902
  const rawEntries = await expandDelimitedPathEntries(effectivePaths, this.session.cwd);
902
903
  const pathSpecs = parsePathSpecs(rawEntries);
903
904
  const paths = pathSpecs.map(spec => spec.clean);
905
+ const materializedExternalPaths = new Map<string, string>();
906
+ const materializeExternalUrlForSearch = async (rawPath: string) => {
907
+ const target = parseReadUrlTarget(rawPath);
908
+ if (!target) return undefined;
909
+ const materialized = await materializeReadUrlToFile(
910
+ this.session,
911
+ { path: target.path, raw: target.raw },
912
+ signal,
913
+ );
914
+ materializedExternalPaths.set(rawPath, materialized.path);
915
+ return { sourcePath: materialized.path, immutable: true };
916
+ };
904
917
  const {
905
918
  resolvedPaths,
906
919
  displayMap: archiveDisplayMap,
@@ -921,34 +934,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
921
934
  });
922
935
  const searchablePaths = internalResolution.paths;
923
936
  const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
924
- // Build the per-file line-range filter (keyed by absolute path) now that
925
- // archive entries have been materialized to scratch files. Plain entries
926
- // resolve through `resolveReadPath`; archive entries are keyed by the
927
- // scratch path that grep will actually report against.
928
937
  const rangesByAbsPath = new Map<string, LineRange[]>();
929
- for (let idx = 0; idx < pathSpecs.length; idx++) {
930
- const spec = pathSpecs[idx];
931
- if (!spec.ranges) continue;
932
- if (virtualInputIndexes.has(idx)) continue;
933
- const resolved = internalResolution.resolvedPathsByInput[idx];
934
- if (!resolved) continue;
935
- if (resolved === spec.clean && !archiveDisplayMap.has(resolved)) {
936
- // Non-archive entry; ensure the cleaned path resolves to a regular file.
937
- const absKey = path.resolve(resolveReadPath(resolved, this.session.cwd));
938
- const stats = await stat(absKey).catch(() => null);
939
- if (!stats) {
940
- throw new ToolError(`Path not found for line-range selector: ${spec.original}`);
941
- }
942
- if (!stats.isFile()) {
943
- throw new ToolError(`Line-range selector requires a single file: ${spec.original} is a directory`);
944
- }
945
- mergeRangesInto(rangesByAbsPath, absKey, spec.ranges);
946
- } else {
947
- // Archive entry — `resolveArchiveSearchPaths` substituted a scratch path.
948
- const absKey = path.resolve(resolved);
949
- mergeRangesInto(rangesByAbsPath, absKey, spec.ranges);
950
- }
951
- }
952
938
 
953
939
  if (
954
940
  archiveUnreadable.length > 0 &&
@@ -991,6 +977,7 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
991
977
  signal,
992
978
  localProtocolOptions: this.session.localProtocolOptions,
993
979
  skills: this.session.skills,
980
+ resolveExternalUrl: materializeExternalUrlForSearch,
994
981
  });
995
982
  searchPath = scope.searchPath;
996
983
  isDirectory = scope.isDirectory;
@@ -1001,6 +988,37 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
1001
988
  for (const immutablePath of scope.immutableSourcePaths) {
1002
989
  immutableSourcePaths.add(immutablePath);
1003
990
  }
991
+ // Build the per-file line-range filter after URL materialization has run:
992
+ // archive entries are keyed by scratch path, URL entries by read-cache
993
+ // content path, and ordinary files by their resolved filesystem path.
994
+ for (let idx = 0; idx < pathSpecs.length; idx++) {
995
+ const spec = pathSpecs[idx];
996
+ if (!spec.ranges) continue;
997
+ if (virtualInputIndexes.has(idx)) continue;
998
+ const resolved = internalResolution.resolvedPathsByInput[idx];
999
+ if (!resolved) continue;
1000
+ const materializedExternalPath = materializedExternalPaths.get(spec.clean);
1001
+ if (materializedExternalPath) {
1002
+ mergeRangesInto(rangesByAbsPath, path.resolve(materializedExternalPath), spec.ranges);
1003
+ continue;
1004
+ }
1005
+ if (resolved === spec.clean && !archiveDisplayMap.has(resolved)) {
1006
+ // Non-archive entry; ensure the cleaned path resolves to a regular file.
1007
+ const absKey = path.resolve(resolveReadPath(resolved, this.session.cwd));
1008
+ const stats = await stat(absKey).catch(() => null);
1009
+ if (!stats) {
1010
+ throw new ToolError(`Path not found for line-range selector: ${spec.original}`);
1011
+ }
1012
+ if (!stats.isFile()) {
1013
+ throw new ToolError(
1014
+ `Line-range selector requires a single file: ${spec.original} is a directory`,
1015
+ );
1016
+ }
1017
+ mergeRangesInto(rangesByAbsPath, absKey, spec.ranges);
1018
+ } else {
1019
+ mergeRangesInto(rangesByAbsPath, path.resolve(resolved), spec.ranges);
1020
+ }
1021
+ }
1004
1022
  // When the only input was an archive selector, surface that selector instead
1005
1023
  // of the temp scratch path the resolver substituted in.
1006
1024
  const physicalScopePath =
@@ -40,7 +40,7 @@ import { AstGrepTool } from "./ast-grep";
40
40
  import { BashTool } from "./bash";
41
41
  import { BrowserTool } from "./browser";
42
42
  import { type BuiltinToolName, normalizeToolNames } from "./builtin-names";
43
- import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
43
+ import { type CheckpointState, CheckpointTool, type CompletedRewindState, RewindTool } from "./checkpoint";
44
44
  import { DebugTool } from "./debug";
45
45
  import { EvalTool } from "./eval";
46
46
  import { resolveEvalBackends } from "./eval-backends";
@@ -332,6 +332,8 @@ export interface ToolSession {
332
332
  getCheckpointState?: () => CheckpointState | undefined;
333
333
  /** Set or clear active checkpoint state. */
334
334
  setCheckpointState?: (state: CheckpointState | null) => void;
335
+ /** Get the most recent completed rewind, if this session just rewound a checkpoint. */
336
+ getLastCompletedRewind?: () => CompletedRewindState | undefined;
335
337
 
336
338
  /** Per-session snapshot store of file contents as last shown to the model
337
339
  * by `read`/`search`. Used by hashline anchor-stale recovery to
package/src/tools/irc.ts CHANGED
@@ -97,6 +97,7 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
97
97
  readonly description: string;
98
98
  readonly parameters = ircSchema;
99
99
  readonly strict = true;
100
+ readonly interruptible = true;
100
101
 
101
102
  readonly examples: readonly ToolExample<typeof ircSchema.infer>[] = [
102
103
  {
@@ -317,16 +318,30 @@ export class IrcTool implements AgentTool<typeof ircSchema, IrcDetails> {
317
318
  lines.push("");
318
319
  if (delivered.length > 0) {
319
320
  const reply = await waiting;
320
- if (reply.error) throw reply.error;
321
- waited = reply.message;
322
- if (waited) {
323
- lines.push(`Reply from ${waited.from}:`);
324
- lines.push(waited.body);
321
+ if (reply.error) {
322
+ // The send already succeeded; if the wait was interrupted by our
323
+ // caller signal (steering / IRC), preserve the delivery receipt so
324
+ // the agent loop keeps this tool as "sent" instead of marking it
325
+ // skipped, which would prompt a duplicate resend on the next turn.
326
+ if (signal?.aborted) {
327
+ lines.push(
328
+ `Send delivered but the reply wait was interrupted before ${to} answered. ` +
329
+ "Check `inbox` or `wait` again after handling the interrupt.",
330
+ );
331
+ } else {
332
+ throw reply.error;
333
+ }
325
334
  } else {
326
- lines.push(
327
- `No reply from ${to} within ${formatDuration(timeoutMs)}. ` +
328
- "They may answer later — check `inbox` or `wait` again.",
329
- );
335
+ waited = reply.message;
336
+ if (waited) {
337
+ lines.push(`Reply from ${waited.from}:`);
338
+ lines.push(waited.body);
339
+ } else {
340
+ lines.push(
341
+ `No reply from ${to} within ${formatDuration(timeoutMs)}. ` +
342
+ "They may answer later — check `inbox` or `wait` again.",
343
+ );
344
+ }
330
345
  }
331
346
  } else {
332
347
  awaitAbort?.abort(awaitCancelled);
@@ -385,6 +385,56 @@ export function formatFullOutputReference(artifactId: string): string {
385
385
  return `Read artifact://${artifactId} for full output`;
386
386
  }
387
387
 
388
+ const RAW_OUTPUT_ARTIFACT_PREFIX = "[raw output: artifact://";
389
+ const RAW_OUTPUT_ARTIFACT_SUFFIX = "]";
390
+
391
+ /** Remove the trailing bash raw-output artifact footer while preserving its artifact id. */
392
+ export function stripRawOutputArtifactNotice(text: string): { text: string; artifactId?: string } {
393
+ const trimmed = text.trimEnd();
394
+ const lineStart = trimmed.lastIndexOf("\n");
395
+ const candidateStart = lineStart === -1 ? 0 : lineStart + 1;
396
+ if (
397
+ !trimmed.startsWith(RAW_OUTPUT_ARTIFACT_PREFIX, candidateStart) ||
398
+ !trimmed.endsWith(RAW_OUTPUT_ARTIFACT_SUFFIX)
399
+ ) {
400
+ return { text };
401
+ }
402
+
403
+ const idStart = candidateStart + RAW_OUTPUT_ARTIFACT_PREFIX.length;
404
+ const idEnd = trimmed.length - RAW_OUTPUT_ARTIFACT_SUFFIX.length;
405
+ if (idStart === idEnd) return { text };
406
+ for (let i = idStart; i < idEnd; i++) {
407
+ const code = trimmed.charCodeAt(i);
408
+ if (code < 48 || code > 57) return { text };
409
+ }
410
+
411
+ const artifactId = trimmed.slice(idStart, idEnd);
412
+ return {
413
+ text: trimmed.slice(0, lineStart === -1 ? 0 : lineStart).trimEnd(),
414
+ artifactId,
415
+ };
416
+ }
417
+
418
+ function isGeneratedOutputNoticeLine(line: string): boolean {
419
+ if (!line.startsWith("[") || !line.endsWith("]")) return false;
420
+ const body = line.slice(1, -1);
421
+ return (
422
+ body.startsWith("Showing ") ||
423
+ /^\d+ matches limit reached\. Use limit=\d+ for more/u.test(body) ||
424
+ /^\d+ results limit reached\. Use limit=\d+ for more/u.test(body) ||
425
+ body.startsWith("Some lines truncated to ")
426
+ );
427
+ }
428
+
429
+ /** Remove a trailing generated output notice when metadata is unavailable. */
430
+ export function stripGeneratedOutputNotice(text: string): string {
431
+ const trimmed = text.trimEnd();
432
+ const lineStart = trimmed.lastIndexOf("\n");
433
+ const candidateStart = lineStart === -1 ? 0 : lineStart + 1;
434
+ if (!isGeneratedOutputNoticeLine(trimmed.slice(candidateStart))) return text;
435
+ return trimmed.slice(0, lineStart === -1 ? 0 : lineStart).trimEnd();
436
+ }
437
+
388
438
  export function formatTruncationMetaNotice(truncation: TruncationMeta): string {
389
439
  let notice: string;
390
440