@oh-my-pi/pi-coding-agent 13.3.6 → 13.3.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 (68) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/package.json +9 -18
  3. package/scripts/format-prompts.ts +7 -172
  4. package/src/capability/mcp.ts +5 -0
  5. package/src/cli/args.ts +1 -0
  6. package/src/config/prompt-templates.ts +9 -55
  7. package/src/config/settings-schema.ts +24 -0
  8. package/src/discovery/builtin.ts +1 -0
  9. package/src/discovery/codex.ts +1 -2
  10. package/src/discovery/helpers.ts +0 -5
  11. package/src/discovery/mcp-json.ts +2 -0
  12. package/src/internal-urls/docs-index.generated.ts +1 -1
  13. package/src/lsp/client.ts +8 -0
  14. package/src/lsp/config.ts +2 -3
  15. package/src/lsp/index.ts +379 -99
  16. package/src/lsp/render.ts +21 -31
  17. package/src/lsp/types.ts +21 -8
  18. package/src/lsp/utils.ts +193 -1
  19. package/src/mcp/config-writer.ts +3 -0
  20. package/src/mcp/config.ts +1 -0
  21. package/src/mcp/oauth-flow.ts +3 -1
  22. package/src/mcp/types.ts +5 -0
  23. package/src/modes/components/settings-defs.ts +9 -0
  24. package/src/modes/components/status-line.ts +1 -1
  25. package/src/modes/controllers/mcp-command-controller.ts +6 -2
  26. package/src/modes/interactive-mode.ts +8 -1
  27. package/src/modes/theme/mermaid-cache.ts +4 -4
  28. package/src/modes/theme/theme.ts +33 -0
  29. package/src/prompts/system/custom-system-prompt.md +0 -10
  30. package/src/prompts/system/subagent-user-prompt.md +2 -0
  31. package/src/prompts/system/system-prompt.md +12 -9
  32. package/src/prompts/tools/ast-find.md +20 -0
  33. package/src/prompts/tools/ast-replace.md +21 -0
  34. package/src/prompts/tools/bash.md +2 -0
  35. package/src/prompts/tools/hashline.md +26 -8
  36. package/src/prompts/tools/lsp.md +22 -5
  37. package/src/prompts/tools/task.md +0 -1
  38. package/src/sdk.ts +11 -5
  39. package/src/session/agent-session.ts +293 -83
  40. package/src/system-prompt.ts +3 -34
  41. package/src/task/executor.ts +8 -7
  42. package/src/task/index.ts +8 -55
  43. package/src/task/template.ts +2 -4
  44. package/src/task/types.ts +0 -5
  45. package/src/task/worktree.ts +6 -2
  46. package/src/tools/ast-find.ts +316 -0
  47. package/src/tools/ast-replace.ts +294 -0
  48. package/src/tools/bash.ts +2 -1
  49. package/src/tools/browser.ts +2 -8
  50. package/src/tools/fetch.ts +55 -18
  51. package/src/tools/index.ts +8 -0
  52. package/src/tools/jtd-to-json-schema.ts +29 -13
  53. package/src/tools/path-utils.ts +34 -0
  54. package/src/tools/python.ts +2 -1
  55. package/src/tools/renderers.ts +4 -0
  56. package/src/tools/ssh.ts +2 -1
  57. package/src/tools/submit-result.ts +143 -44
  58. package/src/tools/todo-write.ts +34 -0
  59. package/src/tools/tool-timeouts.ts +29 -0
  60. package/src/utils/mime.ts +37 -14
  61. package/src/utils/prompt-format.ts +172 -0
  62. package/src/web/scrapers/arxiv.ts +12 -12
  63. package/src/web/scrapers/go-pkg.ts +2 -2
  64. package/src/web/scrapers/iacr.ts +17 -9
  65. package/src/web/scrapers/readthedocs.ts +3 -3
  66. package/src/web/scrapers/twitter.ts +11 -11
  67. package/src/web/scrapers/wikipedia.ts +4 -5
  68. package/src/utils/ignore-files.ts +0 -119
@@ -0,0 +1,294 @@
1
+ import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { astReplace } from "@oh-my-pi/pi-natives";
3
+ import type { Component } from "@oh-my-pi/pi-tui";
4
+ import { Text } from "@oh-my-pi/pi-tui";
5
+ import { untilAborted } from "@oh-my-pi/pi-utils";
6
+ import { type Static, Type } from "@sinclair/typebox";
7
+ import { renderPromptTemplate } from "../config/prompt-templates";
8
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
9
+ import type { Theme } from "../modes/theme/theme";
10
+ import astReplaceDescription from "../prompts/tools/ast-replace.md" with { type: "text" };
11
+ import { Ellipsis, Hasher, type RenderCache, renderStatusLine, renderTreeList, truncateToWidth } from "../tui";
12
+ import type { ToolSession } from ".";
13
+ import type { OutputMeta } from "./output-meta";
14
+ import { hasGlobPathChars, parseSearchPath, resolveToCwd } from "./path-utils";
15
+ import { formatCount, formatEmptyMessage, formatErrorMessage, PREVIEW_LIMITS } from "./render-utils";
16
+ import { ToolError } from "./tool-errors";
17
+ import { toolResult } from "./tool-result";
18
+
19
+ const astReplaceSchema = Type.Object({
20
+ pattern: Type.String({ description: "AST pattern to match" }),
21
+ rewrite: Type.String({ description: "Rewrite template" }),
22
+ lang: Type.Optional(Type.String({ description: "Language override" })),
23
+ path: Type.Optional(Type.String({ description: "File, directory, or glob pattern to rewrite (default: cwd)" })),
24
+ selector: Type.Optional(Type.String({ description: "Optional selector for contextual pattern mode" })),
25
+ dry_run: Type.Optional(Type.Boolean({ description: "Preview only (default: true)" })),
26
+ max_replacements: Type.Optional(Type.Number({ description: "Safety cap on total replacements" })),
27
+ max_files: Type.Optional(Type.Number({ description: "Safety cap on touched files" })),
28
+ });
29
+
30
+ export interface AstReplaceToolDetails {
31
+ totalReplacements: number;
32
+ filesTouched: number;
33
+ filesSearched: number;
34
+ applied: boolean;
35
+ limitReached: boolean;
36
+ parseErrors?: string[];
37
+ meta?: OutputMeta;
38
+ }
39
+
40
+ export class AstReplaceTool implements AgentTool<typeof astReplaceSchema, AstReplaceToolDetails> {
41
+ readonly name = "ast_replace";
42
+ readonly label = "AST Replace";
43
+ readonly description: string;
44
+ readonly parameters = astReplaceSchema;
45
+ readonly strict = true;
46
+
47
+ constructor(private readonly session: ToolSession) {
48
+ this.description = renderPromptTemplate(astReplaceDescription);
49
+ }
50
+
51
+ async execute(
52
+ _toolCallId: string,
53
+ params: Static<typeof astReplaceSchema>,
54
+ signal?: AbortSignal,
55
+ _onUpdate?: AgentToolUpdateCallback<AstReplaceToolDetails>,
56
+ _context?: AgentToolContext,
57
+ ): Promise<AgentToolResult<AstReplaceToolDetails>> {
58
+ return untilAborted(signal, async () => {
59
+ const pattern = params.pattern?.trim();
60
+ if (!pattern) {
61
+ throw new ToolError("`pattern` is required");
62
+ }
63
+ if (!params.rewrite?.trim()) {
64
+ throw new ToolError("`rewrite` is required");
65
+ }
66
+
67
+ const maxReplacements =
68
+ params.max_replacements === undefined ? undefined : Math.floor(params.max_replacements);
69
+ if (maxReplacements !== undefined && (!Number.isFinite(maxReplacements) || maxReplacements < 1)) {
70
+ throw new ToolError("max_replacements must be a positive number");
71
+ }
72
+ const maxFiles = params.max_files === undefined ? undefined : Math.floor(params.max_files);
73
+ if (maxFiles !== undefined && (!Number.isFinite(maxFiles) || maxFiles < 1)) {
74
+ throw new ToolError("max_files must be a positive number");
75
+ }
76
+
77
+ let searchPath: string | undefined;
78
+ let globFilter: string | undefined;
79
+ const rawPath = params.path?.trim();
80
+ if (rawPath) {
81
+ const internalRouter = this.session.internalRouter;
82
+ if (internalRouter?.canHandle(rawPath)) {
83
+ if (hasGlobPathChars(rawPath)) {
84
+ throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
85
+ }
86
+ const resource = await internalRouter.resolve(rawPath);
87
+ if (!resource.sourcePath) {
88
+ throw new ToolError(`Cannot rewrite internal URL without backing file: ${rawPath}`);
89
+ }
90
+ searchPath = resource.sourcePath;
91
+ } else {
92
+ const parsedPath = parseSearchPath(rawPath);
93
+ searchPath = resolveToCwd(parsedPath.basePath, this.session.cwd);
94
+ globFilter = parsedPath.glob;
95
+ }
96
+ }
97
+
98
+ const result = await astReplace({
99
+ pattern,
100
+ rewrite: params.rewrite?.trim(),
101
+ lang: params.lang?.trim(),
102
+ path: searchPath,
103
+ glob: globFilter,
104
+ selector: params.selector?.trim(),
105
+ dryRun: params.dry_run,
106
+ maxReplacements,
107
+ maxFiles,
108
+ failOnParseError: false,
109
+ signal,
110
+ });
111
+
112
+ const details: AstReplaceToolDetails = {
113
+ totalReplacements: result.totalReplacements,
114
+ filesTouched: result.filesTouched,
115
+ filesSearched: result.filesSearched,
116
+ applied: result.applied,
117
+ limitReached: result.limitReached,
118
+ parseErrors: result.parseErrors,
119
+ };
120
+
121
+ const action = result.applied ? "Applied" : "Would apply";
122
+ const lines = [
123
+ `${action} ${result.totalReplacements} replacements across ${result.filesTouched} files (searched ${result.filesSearched})`,
124
+ ];
125
+ if (result.fileChanges.length > 0) {
126
+ lines.push("", "File changes:");
127
+ for (const file of result.fileChanges) {
128
+ lines.push(`- ${file.path}: ${file.count}`);
129
+ }
130
+ }
131
+ if (result.changes.length > 0) {
132
+ lines.push("", "Preview:");
133
+ for (const change of result.changes.slice(0, 30)) {
134
+ const before = (change.before.split("\n", 1)[0] ?? "").slice(0, 80);
135
+ const after = (change.after.split("\n", 1)[0] ?? "").slice(0, 80);
136
+ lines.push(`${change.path}:${change.startLine}:${change.startColumn} ${before} -> ${after}`);
137
+ }
138
+ if (result.changes.length > 30) {
139
+ lines.push(`... ${result.changes.length - 30} more changes`);
140
+ }
141
+ }
142
+ if (result.limitReached) {
143
+ lines.push("", "Safety cap reached; narrow path pattern or increase max_files/max_replacements.");
144
+ }
145
+ if (result.parseErrors?.length) {
146
+ lines.push("", "Parse issues:", ...result.parseErrors.map(err => `- ${err}`));
147
+ }
148
+
149
+ return toolResult(details).text(lines.join("\n")).done();
150
+ });
151
+ }
152
+ }
153
+
154
+ // =============================================================================
155
+ // TUI Renderer
156
+ // =============================================================================
157
+
158
+ interface AstReplaceRenderArgs {
159
+ pattern?: string;
160
+ rewrite?: string;
161
+ lang?: string;
162
+ path?: string;
163
+ selector?: string;
164
+ dry_run?: boolean;
165
+ max_replacements?: number;
166
+ max_files?: number;
167
+ fail_on_parse_error?: boolean;
168
+ }
169
+
170
+ const COLLAPSED_CHANGE_LIMIT = PREVIEW_LIMITS.COLLAPSED_LINES * 2;
171
+
172
+ export const astReplaceToolRenderer = {
173
+ inline: true,
174
+ renderCall(args: AstReplaceRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
175
+ const meta: string[] = [];
176
+ if (args.lang) meta.push(`lang:${args.lang}`);
177
+ if (args.path) meta.push(`in ${args.path}`);
178
+ if (args.dry_run !== false) meta.push("dry run");
179
+ if (args.max_replacements !== undefined) meta.push(`max:${args.max_replacements}`);
180
+ if (args.max_files !== undefined) meta.push(`max files:${args.max_files}`);
181
+
182
+ const description = args.pattern || "?";
183
+ const text = renderStatusLine({ icon: "pending", title: "AST Replace", description, meta }, uiTheme);
184
+ return new Text(text, 0, 0);
185
+ },
186
+
187
+ renderResult(
188
+ result: { content: Array<{ type: string; text?: string }>; details?: AstReplaceToolDetails; isError?: boolean },
189
+ options: RenderResultOptions,
190
+ uiTheme: Theme,
191
+ args?: AstReplaceRenderArgs,
192
+ ): Component {
193
+ const details = result.details;
194
+
195
+ if (result.isError) {
196
+ const errorText = result.content?.find(c => c.type === "text")?.text || "Unknown error";
197
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
198
+ }
199
+
200
+ const totalReplacements = details?.totalReplacements ?? 0;
201
+ const filesTouched = details?.filesTouched ?? 0;
202
+ const filesSearched = details?.filesSearched ?? 0;
203
+ const applied = details?.applied ?? false;
204
+ const limitReached = details?.limitReached ?? false;
205
+
206
+ if (totalReplacements === 0) {
207
+ const description = args?.pattern;
208
+ const meta = ["0 replacements"];
209
+ if (filesSearched > 0) meta.push(`searched ${filesSearched}`);
210
+ const header = renderStatusLine({ icon: "warning", title: "AST Replace", description, meta }, uiTheme);
211
+ return new Text([header, formatEmptyMessage("No replacements made", uiTheme)].join("\n"), 0, 0);
212
+ }
213
+
214
+ const summaryParts = [
215
+ formatCount("replacement", totalReplacements),
216
+ formatCount("file", filesTouched),
217
+ `searched ${filesSearched}`,
218
+ ];
219
+ const meta = [...summaryParts];
220
+ if (limitReached) meta.push(uiTheme.fg("warning", "limit reached"));
221
+ const description = args?.pattern;
222
+ const badge = applied
223
+ ? { label: "applied", color: "success" as const }
224
+ : { label: "dry run", color: "warning" as const };
225
+ const header = renderStatusLine(
226
+ { icon: limitReached ? "warning" : "success", title: "AST Replace", description, badge, meta },
227
+ uiTheme,
228
+ );
229
+
230
+ // Parse text content into display groups
231
+ const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
232
+ const rawLines = textContent.split("\n");
233
+ // Skip the summary line and group by blank-line separators
234
+ const contentLines = rawLines.slice(1);
235
+ const allGroups: string[][] = [];
236
+ let current: string[] = [];
237
+ for (const line of contentLines) {
238
+ if (line.trim().length === 0) {
239
+ if (current.length > 0) {
240
+ allGroups.push(current);
241
+ current = [];
242
+ }
243
+ continue;
244
+ }
245
+ current.push(line);
246
+ }
247
+ if (current.length > 0) allGroups.push(current);
248
+
249
+ // Filter out trailing metadata groups (safety cap / parse issues) — shown via details
250
+ const displayGroups = allGroups.filter(
251
+ group => !group[0]?.startsWith("Safety cap") && !group[0]?.startsWith("Parse issues"),
252
+ );
253
+
254
+ const extraLines: string[] = [];
255
+ if (limitReached) {
256
+ extraLines.push(uiTheme.fg("warning", "safety cap reached; narrow scope or increase limits"));
257
+ }
258
+ if (details?.parseErrors?.length) {
259
+ extraLines.push(uiTheme.fg("warning", `${details.parseErrors.length} parse issue(s)`));
260
+ }
261
+
262
+ let cached: RenderCache | undefined;
263
+ return {
264
+ render(width: number): string[] {
265
+ const { expanded } = options;
266
+ const key = new Hasher().bool(expanded).u32(width).digest();
267
+ if (cached?.key === key) return cached.lines;
268
+ const matchLines = renderTreeList(
269
+ {
270
+ items: displayGroups,
271
+ expanded,
272
+ maxCollapsed: expanded ? displayGroups.length : COLLAPSED_CHANGE_LIMIT,
273
+ itemType: "section",
274
+ renderItem: group =>
275
+ group.map(line => {
276
+ if (line === "File changes:" || line === "Preview:") return uiTheme.fg("accent", line);
277
+ if (line.startsWith("- ")) return uiTheme.fg("toolOutput", line);
278
+ if (line.startsWith("...")) return uiTheme.fg("dim", line);
279
+ return uiTheme.fg("toolOutput", line);
280
+ }),
281
+ },
282
+ uiTheme,
283
+ );
284
+ const result = [header, ...matchLines, ...extraLines].map(l => truncateToWidth(l, width, Ellipsis.Omit));
285
+ cached = { key, lines: result };
286
+ return result;
287
+ },
288
+ invalidate() {
289
+ cached = undefined;
290
+ },
291
+ };
292
+ },
293
+ mergeCallAndResult: true,
294
+ };
package/src/tools/bash.ts CHANGED
@@ -25,6 +25,7 @@ import { resolveToCwd } from "./path-utils";
25
25
  import { replaceTabs } from "./render-utils";
26
26
  import { ToolAbortError, ToolError } from "./tool-errors";
27
27
  import { toolResult } from "./tool-result";
28
+ import { clampTimeout } from "./tool-timeouts";
28
29
 
29
30
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
30
31
 
@@ -200,7 +201,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
200
201
  }
201
202
 
202
203
  // Clamp to reasonable range: 1s - 3600s (1 hour)
203
- const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
204
+ const timeoutSec = clampTimeout("bash", rawTimeout);
204
205
  const timeoutMs = timeoutSec * 1000;
205
206
 
206
207
  if (asyncRequested) {
@@ -37,6 +37,7 @@ import stealthCodecsScript from "./puppeteer/12_stealth_codecs.txt" with { type:
37
37
  import stealthWorkerScript from "./puppeteer/13_stealth_worker.txt" with { type: "text" };
38
38
  import { ToolAbortError, ToolError, throwIfAborted } from "./tool-errors";
39
39
  import { toolResult } from "./tool-result";
40
+ import { clampTimeout } from "./tool-timeouts";
40
41
 
41
42
  /**
42
43
  * Lazy-import puppeteer from a safe CWD so cosmiconfig doesn't choke
@@ -57,8 +58,6 @@ async function loadPuppeteer(): Promise<typeof Puppeteer> {
57
58
  }
58
59
  }
59
60
 
60
- const DEFAULT_TIMEOUT_SECONDS = 30;
61
- const MAX_TIMEOUT_SECONDS = 120;
62
61
  const DEFAULT_VIEWPORT = { width: 1365, height: 768, deviceScaleFactor: 1.25 };
63
62
  const STEALTH_IGNORE_DEFAULT_ARGS = [
64
63
  "--disable-extensions",
@@ -452,11 +451,6 @@ export interface ReadableResult {
452
451
  markdown?: string;
453
452
  }
454
453
 
455
- function clampTimeout(timeoutSeconds?: number): number {
456
- if (timeoutSeconds === undefined) return DEFAULT_TIMEOUT_SECONDS;
457
- return Math.min(Math.max(timeoutSeconds, 1), MAX_TIMEOUT_SECONDS);
458
- }
459
-
460
454
  function ensureParam<T>(value: T | undefined, name: string, action: string): T {
461
455
  if (value === undefined || value === null || value === "") {
462
456
  throw new ToolError(`Missing required parameter '${name}' for action '${action}'.`);
@@ -956,7 +950,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
956
950
  ): Promise<AgentToolResult<BrowserToolDetails>> {
957
951
  try {
958
952
  throwIfAborted(signal);
959
- const timeoutSeconds = clampTimeout(params.timeout);
953
+ const timeoutSeconds = clampTimeout("browser", params.timeout);
960
954
  const timeoutMs = timeoutSeconds * 1000;
961
955
  const details: BrowserToolDetails = { action: params.action };
962
956
 
@@ -5,7 +5,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
5
5
  import { Text } from "@oh-my-pi/pi-tui";
6
6
  import { ptree, truncate } from "@oh-my-pi/pi-utils";
7
7
  import { type Static, Type } from "@sinclair/typebox";
8
- import { parse as parseHtml } from "node-html-parser";
8
+ import { parseHTML } from "linkedom";
9
9
  import { renderPromptTemplate } from "../config/prompt-templates";
10
10
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
11
11
  import { type Theme, theme } from "../modes/theme/theme";
@@ -24,6 +24,7 @@ import { formatStyledArtifactReference, type OutputMeta } from "./output-meta";
24
24
  import { formatExpandHint, getDomain } from "./render-utils";
25
25
  import { ToolAbortError } from "./tool-errors";
26
26
  import { toolResult } from "./tool-result";
27
+ import { clampTimeout } from "./tool-timeouts";
27
28
 
28
29
  // =============================================================================
29
30
  // Types and Constants
@@ -248,6 +249,36 @@ async function tryContentNegotiation(
248
249
  return null;
249
250
  }
250
251
 
252
+ /**
253
+ * Read a single HTML attribute from a tag string
254
+ */
255
+ function getHtmlAttribute(tag: string, attribute: string): string | null {
256
+ const pattern = new RegExp(`\\b${attribute}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'=<>]+))`, "i");
257
+ const match = tag.match(pattern);
258
+ if (!match) return null;
259
+ return (match[1] ?? match[2] ?? match[3] ?? "").trim();
260
+ }
261
+
262
+ /**
263
+ * Extract bounded <head> markup to avoid expensive whole-page parsing
264
+ */
265
+ function extractHeadHtml(html: string): string {
266
+ const lower = html.toLowerCase();
267
+ const headStart = lower.indexOf("<head");
268
+ if (headStart === -1) {
269
+ return html.slice(0, 32 * 1024);
270
+ }
271
+
272
+ const headTagEnd = html.indexOf(">", headStart);
273
+ if (headTagEnd === -1) {
274
+ return html.slice(headStart, headStart + 32 * 1024);
275
+ }
276
+
277
+ const headEnd = lower.indexOf("</head>", headTagEnd + 1);
278
+ const fallbackEnd = Math.min(html.length, headTagEnd + 1 + 32 * 1024);
279
+ return html.slice(headStart, headEnd === -1 ? fallbackEnd : headEnd + 7);
280
+ }
281
+
251
282
  /**
252
283
  * Parse alternate links from HTML head
253
284
  */
@@ -255,13 +286,17 @@ function parseAlternateLinks(html: string, pageUrl: string): string[] {
255
286
  const links: string[] = [];
256
287
 
257
288
  try {
258
- const doc = parseHtml(html.slice(0, 262144));
259
- const alternateLinks = doc.querySelectorAll('link[rel="alternate"]');
289
+ const pagePath = new URL(pageUrl).pathname;
290
+ const headHtml = extractHeadHtml(html);
291
+ const linkTags = headHtml.match(/<link\b[^>]*>/gi) ?? [];
260
292
 
261
- for (const link of alternateLinks) {
262
- const href = link.getAttribute("href");
263
- const type = link.getAttribute("type")?.toLowerCase() ?? "";
293
+ for (const tag of linkTags) {
294
+ const rel = getHtmlAttribute(tag, "rel")?.toLowerCase() ?? "";
295
+ const relTokens = rel.split(/\s+/).filter(Boolean);
296
+ if (!relTokens.includes("alternate")) continue;
264
297
 
298
+ const href = getHtmlAttribute(tag, "href");
299
+ const type = getHtmlAttribute(tag, "type")?.toLowerCase() ?? "";
265
300
  if (!href) continue;
266
301
 
267
302
  // Skip site-wide feeds
@@ -278,7 +313,7 @@ function parseAlternateLinks(html: string, pageUrl: string): string[] {
278
313
  links.push(href);
279
314
  } else if (
280
315
  (type.includes("rss") || type.includes("atom") || type.includes("feed")) &&
281
- (href.includes(new URL(pageUrl).pathname) || href.includes("comments"))
316
+ (href.includes(pagePath) || href.includes("comments"))
282
317
  ) {
283
318
  links.push(href);
284
319
  }
@@ -293,20 +328,22 @@ function parseAlternateLinks(html: string, pageUrl: string): string[] {
293
328
  */
294
329
  function extractDocumentLinks(html: string, baseUrl: string): string[] {
295
330
  const links: string[] = [];
331
+ const seen = new Set<string>();
296
332
 
297
333
  try {
298
- const doc = parseHtml(html);
299
- const anchors = doc.querySelectorAll("a[href]");
300
-
301
- for (const anchor of anchors) {
302
- const href = anchor.getAttribute("href");
334
+ const anchorTags = html.slice(0, 512 * 1024).match(/<a\b[^>]*>/gi) ?? [];
335
+ for (const tag of anchorTags) {
336
+ const href = getHtmlAttribute(tag, "href");
303
337
  if (!href) continue;
304
338
 
305
339
  const ext = path.extname(href).toLowerCase();
306
- if (CONVERTIBLE_EXTENSIONS.has(ext)) {
307
- const resolved = href.startsWith("http") ? href : new URL(href, baseUrl).href;
308
- links.push(resolved);
309
- }
340
+ if (!CONVERTIBLE_EXTENSIONS.has(ext)) continue;
341
+
342
+ const resolved = href.startsWith("http") ? href : new URL(href, baseUrl).href;
343
+ if (seen.has(resolved)) continue;
344
+ seen.add(resolved);
345
+ links.push(resolved);
346
+ if (links.length >= 20) break;
310
347
  }
311
348
  } catch {}
312
349
 
@@ -333,7 +370,7 @@ function cleanFeedText(text: string): string {
333
370
  */
334
371
  function parseFeedToMarkdown(content: string, maxItems = 10): string {
335
372
  try {
336
- const doc = parseHtml(content, { parseNoneClosedTags: true });
373
+ const doc = parseHTML(content).document;
337
374
 
338
375
  // Try RSS
339
376
  const channel = doc.querySelector("channel");
@@ -872,7 +909,7 @@ export class FetchTool implements AgentTool<typeof fetchSchema, FetchToolDetails
872
909
  const { url, timeout: rawTimeout = 20, raw = false } = params;
873
910
 
874
911
  // Clamp to valid range (seconds)
875
- const effectiveTimeout = Math.min(Math.max(rawTimeout, 1), 45);
912
+ const effectiveTimeout = clampTimeout("fetch", rawTimeout);
876
913
 
877
914
  if (signal?.aborted) {
878
915
  throw new ToolAbortError();
@@ -15,6 +15,8 @@ import type { AgentOutputManager } from "../task/output-manager";
15
15
  import type { EventBus } from "../utils/event-bus";
16
16
  import { SearchTool } from "../web/search";
17
17
  import { AskTool } from "./ask";
18
+ import { AstFindTool } from "./ast-find";
19
+ import { AstReplaceTool } from "./ast-replace";
18
20
  import { AwaitTool } from "./await-tool";
19
21
  import { BashTool } from "./bash";
20
22
  import { BrowserTool } from "./browser";
@@ -54,6 +56,8 @@ export * from "../session/streaming-output";
54
56
  export { BUNDLED_AGENTS, TaskTool } from "../task";
55
57
  export * from "../web/search";
56
58
  export { AskTool, type AskToolDetails } from "./ask";
59
+ export { AstFindTool, type AstFindToolDetails } from "./ast-find";
60
+ export { AstReplaceTool, type AstReplaceToolDetails } from "./ast-replace";
57
61
  export { AwaitTool, type AwaitToolDetails } from "./await-tool";
58
62
  export { BashTool, type BashToolDetails, type BashToolInput, type BashToolOptions } from "./bash";
59
63
  export { BrowserTool, type BrowserToolDetails } from "./browser";
@@ -155,6 +159,8 @@ export interface ToolSession {
155
159
  type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
156
160
 
157
161
  export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
162
+ ast_find: s => new AstFindTool(s),
163
+ ast_replace: s => new AstReplaceTool(s),
158
164
  ask: AskTool.createIf,
159
165
  bash: s => new BashTool(s),
160
166
  python: s => new PythonTool(s),
@@ -283,6 +289,8 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
283
289
  if (name === "todo_write") return !includeSubmitResult && session.settings.get("todo.enabled");
284
290
  if (name === "find") return session.settings.get("find.enabled");
285
291
  if (name === "grep") return session.settings.get("grep.enabled");
292
+ if (name === "ast_find") return session.settings.get("astFind.enabled");
293
+ if (name === "ast_replace") return session.settings.get("astReplace.enabled");
286
294
  if (name === "notebook") return session.settings.get("notebook.enabled");
287
295
  if (name === "fetch") return session.settings.get("fetch.enabled");
288
296
  if (name === "web_search") return session.settings.get("web_search.enabled");
@@ -38,15 +38,6 @@ function convertSchema(schema: unknown): unknown {
38
38
  return {};
39
39
  }
40
40
 
41
- // Type form: { type: "string" } → { type: "string" }
42
- if (isJTDType(schema)) {
43
- const jsonType = primitiveMap[schema.type as JTDPrimitive];
44
- if (!jsonType) {
45
- return { type: schema.type };
46
- }
47
- return { type: jsonType };
48
- }
49
-
50
41
  // Enum form: { enum: ["a", "b"] } → { enum: ["a", "b"] }
51
42
  if (isJTDEnum(schema)) {
52
43
  return { enum: schema.enum };
@@ -60,6 +51,14 @@ function convertSchema(schema: unknown): unknown {
60
51
  };
61
52
  }
62
53
 
54
+ // Type form: { type: "string" } → { type: "string" }
55
+ if (isJTDType(schema)) {
56
+ const jsonType = primitiveMap[schema.type as JTDPrimitive];
57
+ if (!jsonType) {
58
+ return { type: schema.type };
59
+ }
60
+ return { type: jsonType };
61
+ }
63
62
  // Values form: { values: { type: "string" } } → { type: "object", additionalProperties: ... }
64
63
  if (isJTDValues(schema)) {
65
64
  return {
@@ -171,13 +170,30 @@ export function isJTDSchema(schema: unknown): boolean {
171
170
  return false;
172
171
  }
173
172
 
173
+ function normalizeMixedSchemaNode(schema: unknown): unknown {
174
+ if (schema === null || typeof schema !== "object") {
175
+ return schema;
176
+ }
177
+
178
+ if (Array.isArray(schema)) {
179
+ return schema.map(item => normalizeMixedSchemaNode(item));
180
+ }
181
+
182
+ if (isJTDSchema(schema)) {
183
+ return normalizeMixedSchemaNode(convertSchema(schema));
184
+ }
185
+
186
+ const normalized: Record<string, unknown> = {};
187
+ for (const [key, value] of Object.entries(schema)) {
188
+ normalized[key] = normalizeMixedSchemaNode(value);
189
+ }
190
+
191
+ return normalized;
192
+ }
174
193
  /**
175
194
  * Convert JTD schema to JSON Schema.
176
195
  * If already JSON Schema, returns as-is.
177
196
  */
178
197
  export function jtdToJsonSchema(schema: unknown): unknown {
179
- if (!isJTDSchema(schema)) {
180
- return schema;
181
- }
182
- return convertSchema(schema);
198
+ return normalizeMixedSchemaNode(schema);
183
199
  }
@@ -88,6 +88,40 @@ export function resolveToCwd(filePath: string, cwd: string): string {
88
88
  return path.resolve(cwd, expanded);
89
89
  }
90
90
 
91
+ const GLOB_PATH_CHARS = ["*", "?", "[", "{"] as const;
92
+
93
+ export function hasGlobPathChars(filePath: string): boolean {
94
+ return GLOB_PATH_CHARS.some(char => filePath.includes(char));
95
+ }
96
+
97
+ export interface ParsedSearchPath {
98
+ basePath: string;
99
+ glob?: string;
100
+ }
101
+
102
+ /**
103
+ * Split a user path into a base path + glob pattern for tools that delegate to
104
+ * APIs accepting separate `path` and `glob` arguments.
105
+ */
106
+ export function parseSearchPath(filePath: string): ParsedSearchPath {
107
+ const normalizedPath = filePath.replace(/\\/g, "/");
108
+ if (!hasGlobPathChars(normalizedPath)) {
109
+ return { basePath: filePath };
110
+ }
111
+
112
+ const segments = normalizedPath.split("/");
113
+ const firstGlobIndex = segments.findIndex(segment => hasGlobPathChars(segment));
114
+
115
+ if (firstGlobIndex <= 0) {
116
+ return { basePath: ".", glob: normalizedPath };
117
+ }
118
+
119
+ return {
120
+ basePath: segments.slice(0, firstGlobIndex).join("/"),
121
+ glob: segments.slice(firstGlobIndex).join("/"),
122
+ };
123
+ }
124
+
91
125
  export function resolveReadPath(filePath: string, cwd: string): string {
92
126
  const resolved = resolveToCwd(filePath, cwd);
93
127
 
@@ -21,6 +21,7 @@ import { resolveToCwd } from "./path-utils";
21
21
  import { formatTitle, replaceTabs, shortenPath, truncateToWidth, wrapBrackets } from "./render-utils";
22
22
  import { ToolAbortError, ToolError } from "./tool-errors";
23
23
  import { toolResult } from "./tool-result";
24
+ import { clampTimeout } from "./tool-timeouts";
24
25
 
25
26
  export const PYTHON_DEFAULT_PREVIEW_LINES = 10;
26
27
 
@@ -177,7 +178,7 @@ export class PythonTool implements AgentTool<typeof pythonSchema> {
177
178
 
178
179
  const { cells, timeout: rawTimeout = 30, cwd, reset } = params;
179
180
  // Clamp to reasonable range: 1s - 600s (10 min)
180
- const timeoutSec = Math.max(1, Math.min(600, rawTimeout));
181
+ const timeoutSec = clampTimeout("python", rawTimeout);
181
182
  const timeoutMs = timeoutSec * 1000;
182
183
  const timeoutSignal = AbortSignal.timeout(timeoutMs);
183
184
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
@@ -11,6 +11,8 @@ import { editToolRenderer } from "../patch";
11
11
  import { taskToolRenderer } from "../task/render";
12
12
  import { webSearchToolRenderer } from "../web/search/render";
13
13
  import { askToolRenderer } from "./ask";
14
+ import { astFindToolRenderer } from "./ast-find";
15
+ import { astReplaceToolRenderer } from "./ast-replace";
14
16
  import { bashToolRenderer } from "./bash";
15
17
  import { calculatorToolRenderer } from "./calculator";
16
18
  import { fetchToolRenderer } from "./fetch";
@@ -38,6 +40,8 @@ type ToolRenderer = {
38
40
 
39
41
  export const toolRenderers: Record<string, ToolRenderer> = {
40
42
  ask: askToolRenderer as ToolRenderer,
43
+ ast_find: astFindToolRenderer as ToolRenderer,
44
+ ast_replace: astReplaceToolRenderer as ToolRenderer,
41
45
  bash: bashToolRenderer as ToolRenderer,
42
46
  python: pythonToolRenderer as ToolRenderer,
43
47
  calc: calculatorToolRenderer as ToolRenderer,
package/src/tools/ssh.ts CHANGED
@@ -19,6 +19,7 @@ import type { ToolSession } from ".";
19
19
  import { formatStyledTruncationWarning, type OutputMeta } from "./output-meta";
20
20
  import { ToolError } from "./tool-errors";
21
21
  import { toolResult } from "./tool-result";
22
+ import { clampTimeout } from "./tool-timeouts";
22
23
 
23
24
  const sshSchema = Type.Object({
24
25
  host: Type.String({ description: "Host name from managed SSH config or discovered ssh.json files" }),
@@ -155,7 +156,7 @@ export class SshTool implements AgentTool<typeof sshSchema, SSHToolDetails> {
155
156
  const remoteCommand = buildRemoteCommand(command, cwd, hostInfo);
156
157
 
157
158
  // Clamp to reasonable range: 1s - 3600s (1 hour)
158
- const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
159
+ const timeoutSec = clampTimeout("ssh", rawTimeout);
159
160
  const timeoutMs = timeoutSec * 1000;
160
161
 
161
162
  const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);