@oh-my-pi/pi-coding-agent 15.1.6 → 15.1.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.
- package/CHANGELOG.md +24 -0
- package/dist/types/config/settings-schema.d.ts +15 -7
- package/dist/types/edit/streaming.d.ts +7 -0
- package/dist/types/hashline/hash.d.ts +4 -4
- package/dist/types/hashline/recovery.d.ts +5 -0
- package/dist/types/lsp/edits.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +16 -0
- package/dist/types/session/client-bridge.d.ts +1 -0
- package/dist/types/tools/find.d.ts +4 -0
- package/dist/types/tools/resolve.d.ts +5 -0
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/package.json +7 -7
- package/src/config/settings-schema.ts +22 -7
- package/src/dap/session.ts +58 -5
- package/src/edit/modes/patch.ts +46 -0
- package/src/edit/streaming.ts +145 -4
- package/src/eval/js/context-manager.ts +11 -7
- package/src/eval/js/shared/rewrite-imports.ts +21 -9
- package/src/eval/js/shared/runtime.ts +2 -1
- package/src/hashline/hash.ts +11 -8
- package/src/hashline/parser.ts +23 -6
- package/src/hashline/recovery.ts +44 -3
- package/src/lsp/edits.ts +92 -38
- package/src/lsp/index.ts +110 -7
- package/src/lsp/utils.ts +13 -0
- package/src/modes/acp/acp-client-bridge.ts +1 -0
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/tool-execution.ts +46 -1
- package/src/modes/interactive-mode.ts +33 -7
- package/src/prompts/tools/bash.md +14 -0
- package/src/prompts/tools/debug.md +4 -1
- package/src/prompts/tools/find.md +10 -0
- package/src/prompts/tools/hashline.md +5 -3
- package/src/prompts/tools/resolve.md +1 -1
- package/src/prompts/tools/search.md +2 -1
- package/src/prompts/tools/task.md +4 -0
- package/src/prompts/tools/todo-write.md +2 -0
- package/src/session/agent-session.ts +116 -8
- package/src/session/client-bridge.ts +1 -0
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/task/index.ts +33 -5
- package/src/task/render.ts +4 -1
- package/src/tools/browser/tab-supervisor.ts +23 -3
- package/src/tools/browser/tab-worker.ts +4 -2
- package/src/tools/browser.ts +1 -1
- package/src/tools/debug.ts +19 -2
- package/src/tools/find.ts +80 -24
- package/src/tools/read.ts +3 -6
- package/src/tools/resolve.ts +54 -22
- package/src/tools/search.ts +31 -0
- package/src/tools/todo-write.ts +11 -4
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/utils/tools-manager.ts +29 -22
- package/src/web/search/providers/codex.ts +3 -0
- package/src/web/search/providers/perplexity.ts +24 -1
|
@@ -120,14 +120,34 @@ export async function acquireTab(
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
const initPayload = await buildInitPayload(browser, opts);
|
|
123
|
-
|
|
123
|
+
let worker = await spawnTabWorker();
|
|
124
124
|
let info: ReadyInfo;
|
|
125
125
|
try {
|
|
126
126
|
info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
|
|
127
127
|
} catch (error) {
|
|
128
|
+
// `BuildMessage`-class failures arrive asynchronously via the worker's `error` event,
|
|
129
|
+
// after `spawnTabWorker`'s synchronous try/catch has already returned. Fall back to
|
|
130
|
+
// the inline worker here so module-resolution failures don't poison every tab open.
|
|
128
131
|
await worker.terminate().catch(() => undefined);
|
|
129
|
-
if (
|
|
130
|
-
|
|
132
|
+
if (worker.mode === "inline") {
|
|
133
|
+
if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
logger.warn("Tab worker init failed; retrying with inline tab worker (no sync-loop guard)", {
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
});
|
|
139
|
+
worker = await spawnInlineWorker();
|
|
140
|
+
try {
|
|
141
|
+
info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
|
|
142
|
+
} catch (inlineError) {
|
|
143
|
+
await worker.terminate().catch(() => undefined);
|
|
144
|
+
if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
|
|
145
|
+
const finalError = new ToolError(
|
|
146
|
+
`Failed to start browser tab worker (inline fallback also failed): ${inlineError instanceof Error ? inlineError.message : String(inlineError)}`,
|
|
147
|
+
);
|
|
148
|
+
(finalError as { cause?: unknown }).cause = error;
|
|
149
|
+
throw finalError;
|
|
150
|
+
}
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
holdBrowser(browser);
|
|
@@ -459,7 +459,8 @@ export class WorkerCore {
|
|
|
459
459
|
if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs);
|
|
460
460
|
if (payload.url) {
|
|
461
461
|
await this.#page.goto(payload.url, {
|
|
462
|
-
|
|
462
|
+
// Default to "load" because dev servers with HMR/WS never reach networkidle.
|
|
463
|
+
waitUntil: payload.waitUntil ?? "load",
|
|
463
464
|
timeout: payload.timeoutMs,
|
|
464
465
|
});
|
|
465
466
|
}
|
|
@@ -667,7 +668,8 @@ export class WorkerCore {
|
|
|
667
668
|
goto: async (url, opts) => {
|
|
668
669
|
this.#clearElementCache();
|
|
669
670
|
await untilAborted(signal, () =>
|
|
670
|
-
|
|
671
|
+
// Default to "load" because dev servers with HMR/WS never reach networkidle.
|
|
672
|
+
page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: timeoutMs }),
|
|
671
673
|
);
|
|
672
674
|
},
|
|
673
675
|
observe: opts => this.#collectObservation({ ...opts, signal }),
|
package/src/tools/browser.ts
CHANGED
|
@@ -45,7 +45,7 @@ const browserSchema = z.object({
|
|
|
45
45
|
.describe("auto-handle dialogs")
|
|
46
46
|
.optional(),
|
|
47
47
|
code: z.string().describe("js body to run in tab").optional(),
|
|
48
|
-
timeout: z.number().default(30).describe("timeout in seconds").optional(),
|
|
48
|
+
timeout: z.number().default(30).describe("timeout in seconds (default 30, max 300)").optional(),
|
|
49
49
|
all: z.boolean().describe("close every tab").optional(),
|
|
50
50
|
kill: z.boolean().describe("also kill spawned-app browsers").optional(),
|
|
51
51
|
});
|
package/src/tools/debug.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
1
2
|
import type {
|
|
2
3
|
AgentTool,
|
|
3
4
|
AgentToolContext,
|
|
@@ -6,7 +7,7 @@ import type {
|
|
|
6
7
|
RenderResultOptions,
|
|
7
8
|
} from "@oh-my-pi/pi-agent-core";
|
|
8
9
|
import { type Component, Text } from "@oh-my-pi/pi-tui";
|
|
9
|
-
import { prompt } from "@oh-my-pi/pi-utils";
|
|
10
|
+
import { isEnoent, prompt } from "@oh-my-pi/pi-utils";
|
|
10
11
|
import * as z from "zod/v4";
|
|
11
12
|
import {
|
|
12
13
|
type DapBreakpointRecord,
|
|
@@ -37,7 +38,7 @@ import { renderStatusLine } from "../tui";
|
|
|
37
38
|
import { CachedOutputBlock } from "../tui/output-block";
|
|
38
39
|
import type { ToolSession } from ".";
|
|
39
40
|
import type { OutputMeta } from "./output-meta";
|
|
40
|
-
import { resolveToCwd } from "./path-utils";
|
|
41
|
+
import { formatPathRelativeToCwd, resolveToCwd } from "./path-utils";
|
|
41
42
|
import {
|
|
42
43
|
formatExpandHint,
|
|
43
44
|
formatStatusIcon,
|
|
@@ -469,6 +470,21 @@ function getConfiguredAdapters(cwd: string): string {
|
|
|
469
470
|
const adapters = getAvailableAdapters(cwd).map(adapter => adapter.name);
|
|
470
471
|
return adapters.length > 0 ? adapters.join(", ") : "none";
|
|
471
472
|
}
|
|
473
|
+
async function validateLaunchProgram(program: string, cwd: string): Promise<void> {
|
|
474
|
+
let isDirectory: boolean;
|
|
475
|
+
try {
|
|
476
|
+
isDirectory = (await fs.stat(program)).isDirectory();
|
|
477
|
+
} catch (error) {
|
|
478
|
+
if (isEnoent(error)) return;
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
if (!isDirectory) return;
|
|
482
|
+
|
|
483
|
+
const displayPath = formatPathRelativeToCwd(program, cwd, { trailingSlash: true });
|
|
484
|
+
throw new ToolError(
|
|
485
|
+
`launch program resolves to a directory: ${displayPath}. Pass an executable file path, or for Python use adapter "debugpy" with program set to the .py file.`,
|
|
486
|
+
);
|
|
487
|
+
}
|
|
472
488
|
|
|
473
489
|
interface DebugRenderArgs extends Partial<DebugParams> {}
|
|
474
490
|
|
|
@@ -628,6 +644,7 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
|
|
|
628
644
|
}
|
|
629
645
|
const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
|
|
630
646
|
const program = resolveToCwd(params.program, commandCwd);
|
|
647
|
+
await validateLaunchProgram(program, commandCwd);
|
|
631
648
|
const adapter = selectLaunchAdapter(program, commandCwd, params.adapter);
|
|
632
649
|
if (!adapter) {
|
|
633
650
|
throw new ToolError(
|
package/src/tools/find.ts
CHANGED
|
@@ -38,14 +38,41 @@ const findSchema = z
|
|
|
38
38
|
.object({
|
|
39
39
|
paths: z.array(z.string().describe("glob including search path")).min(1).describe("globs including search paths"),
|
|
40
40
|
hidden: z.boolean().default(true).describe("include hidden files").optional(),
|
|
41
|
+
gitignore: z.boolean().default(true).describe("respect gitignore").optional(),
|
|
41
42
|
limit: z.number().default(1000).describe("max results").optional(),
|
|
43
|
+
timeout: z.number().min(0.5).max(60).default(5).describe("timeout in seconds (0.5–60)").optional(),
|
|
42
44
|
})
|
|
43
45
|
.strict();
|
|
44
46
|
|
|
45
47
|
export type FindToolInput = z.infer<typeof findSchema>;
|
|
46
48
|
|
|
47
49
|
const DEFAULT_LIMIT = 1000;
|
|
48
|
-
const
|
|
50
|
+
const DEFAULT_GLOB_TIMEOUT_MS = 5000;
|
|
51
|
+
const MIN_GLOB_TIMEOUT_MS = 500;
|
|
52
|
+
const MAX_GLOB_TIMEOUT_MS = 60_000;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Reject comma-separated path lists packed into a single array element
|
|
56
|
+
* (`["a.py,b.py"]`). The schema is array-of-string; agents that pass a
|
|
57
|
+
* single comma-joined element get silent no-matches otherwise.
|
|
58
|
+
*
|
|
59
|
+
* Commas inside brace expansion (`{a,b}`) are legitimate glob syntax and
|
|
60
|
+
* must pass through.
|
|
61
|
+
*/
|
|
62
|
+
function validateFindPathInputs(paths: readonly string[]): void {
|
|
63
|
+
for (const entry of paths) {
|
|
64
|
+
let braceDepth = 0;
|
|
65
|
+
for (let i = 0; i < entry.length; i++) {
|
|
66
|
+
const ch = entry.charCodeAt(i);
|
|
67
|
+
if (ch === 0x7b /* { */) braceDepth++;
|
|
68
|
+
else if (ch === 0x7d /* } */) {
|
|
69
|
+
if (braceDepth > 0) braceDepth--;
|
|
70
|
+
} else if (ch === 0x2c /* , */ && braceDepth === 0) {
|
|
71
|
+
throw new ToolError(`paths is an array — pass ["a", "b"] not ["a,b"] (got ${JSON.stringify(entry)})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
49
76
|
|
|
50
77
|
export interface FindToolDetails {
|
|
51
78
|
truncation?: TruncationResult;
|
|
@@ -109,10 +136,11 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
109
136
|
onUpdate?: AgentToolUpdateCallback<FindToolDetails>,
|
|
110
137
|
_context?: AgentToolContext,
|
|
111
138
|
): Promise<AgentToolResult<FindToolDetails>> {
|
|
112
|
-
const { paths, limit, hidden } = params;
|
|
139
|
+
const { paths, limit, hidden, gitignore, timeout } = params;
|
|
113
140
|
|
|
114
141
|
return untilAborted(signal, async () => {
|
|
115
142
|
const formatScopePath = (targetPath: string): string => formatPathRelativeToCwd(targetPath, this.session.cwd);
|
|
143
|
+
validateFindPathInputs(paths);
|
|
116
144
|
const rawPatterns = paths.map(input => normalizePathLikeInput(input).replace(/\\/g, "/"));
|
|
117
145
|
const internalRouter = InternalUrlRouter.instance();
|
|
118
146
|
const normalizedPatterns: string[] = [];
|
|
@@ -165,7 +193,10 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
165
193
|
throw new ToolError("Limit must be a positive number");
|
|
166
194
|
}
|
|
167
195
|
const includeHidden = hidden ?? true;
|
|
168
|
-
const
|
|
196
|
+
const useGitignore = gitignore ?? true;
|
|
197
|
+
const requestedTimeoutMs = timeout != null ? Math.round(timeout * 1000) : DEFAULT_GLOB_TIMEOUT_MS;
|
|
198
|
+
const timeoutMs = Math.min(MAX_GLOB_TIMEOUT_MS, Math.max(MIN_GLOB_TIMEOUT_MS, requestedTimeoutMs));
|
|
199
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
169
200
|
const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
170
201
|
const formatMatchPath = (matchPath: string, fileType?: natives.FileType): string => {
|
|
171
202
|
const hadTrailingSlash = matchPath.endsWith("/") || matchPath.endsWith("\\");
|
|
@@ -178,33 +209,41 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
178
209
|
const missingPathsNote =
|
|
179
210
|
missingPaths.length > 0 ? `Skipped missing paths: ${missingPaths.join(", ")}` : undefined;
|
|
180
211
|
|
|
181
|
-
const buildResult = (
|
|
212
|
+
const buildResult = (
|
|
213
|
+
files: string[],
|
|
214
|
+
opts?: { notice?: string; forceTruncated?: boolean },
|
|
215
|
+
): AgentToolResult<FindToolDetails> => {
|
|
216
|
+
const notice = opts?.notice;
|
|
217
|
+
const forceTruncated = opts?.forceTruncated ?? false;
|
|
182
218
|
if (files.length === 0) {
|
|
183
219
|
const details: FindToolDetails = {
|
|
184
220
|
scopePath,
|
|
185
221
|
fileCount: 0,
|
|
186
222
|
files: [],
|
|
187
|
-
truncated:
|
|
223
|
+
truncated: forceTruncated,
|
|
188
224
|
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
|
|
189
225
|
};
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
return toolResult(details).text(
|
|
226
|
+
const parts = ["No files found matching pattern"];
|
|
227
|
+
if (notice) parts.push(notice);
|
|
228
|
+
if (missingPathsNote) parts.push(missingPathsNote);
|
|
229
|
+
return toolResult(details).text(parts.join("\n")).done();
|
|
194
230
|
}
|
|
195
231
|
|
|
196
232
|
const listLimit = applyListLimit(files, { limit: effectiveLimit });
|
|
197
233
|
const limited = listLimit.items;
|
|
198
234
|
const limitMeta = listLimit.meta;
|
|
199
235
|
const baseOutput = limited.join("\n");
|
|
200
|
-
const
|
|
236
|
+
const trailingNotes: string[] = [];
|
|
237
|
+
if (notice) trailingNotes.push(notice);
|
|
238
|
+
if (missingPathsNote) trailingNotes.push(missingPathsNote);
|
|
239
|
+
const rawOutput = trailingNotes.length > 0 ? `${baseOutput}\n\n${trailingNotes.join("\n")}` : baseOutput;
|
|
201
240
|
const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
|
|
202
241
|
|
|
203
242
|
const details: FindToolDetails = {
|
|
204
243
|
scopePath,
|
|
205
244
|
fileCount: limited.length,
|
|
206
245
|
files: limited,
|
|
207
|
-
truncated: Boolean(limitMeta.resultLimit || truncation.truncated),
|
|
246
|
+
truncated: Boolean(forceTruncated || limitMeta.resultLimit || truncation.truncated),
|
|
208
247
|
resultLimitReached: limitMeta.resultLimit?.reached,
|
|
209
248
|
truncation: truncation.truncated ? truncation : undefined,
|
|
210
249
|
missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
|
|
@@ -278,14 +317,12 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
278
317
|
details,
|
|
279
318
|
});
|
|
280
319
|
};
|
|
281
|
-
const onMatch =
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
}
|
|
288
|
-
: undefined;
|
|
320
|
+
const onMatch = (err: Error | null, match: natives.GlobMatch | null) => {
|
|
321
|
+
if (err || signal?.aborted || !match?.path) return;
|
|
322
|
+
const relativePath = formatMatchPath(match.path, match.fileType);
|
|
323
|
+
onUpdateMatches.push(relativePath);
|
|
324
|
+
emitUpdate();
|
|
325
|
+
};
|
|
289
326
|
|
|
290
327
|
const doGlob = async (useGitignore: boolean) =>
|
|
291
328
|
untilAborted(combinedSignal, () =>
|
|
@@ -304,8 +341,9 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
304
341
|
),
|
|
305
342
|
);
|
|
306
343
|
|
|
344
|
+
let timedOut = false;
|
|
307
345
|
try {
|
|
308
|
-
const result = await doGlob(
|
|
346
|
+
const result = await doGlob(useGitignore);
|
|
309
347
|
// Sort by mtime descending (most recent first) in JS instead of native.
|
|
310
348
|
// This allows native glob to early-terminate at maxResults.
|
|
311
349
|
result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
|
|
@@ -313,12 +351,30 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
|
|
|
313
351
|
} catch (error) {
|
|
314
352
|
if (error instanceof Error && error.name === "AbortError") {
|
|
315
353
|
if (timeoutSignal.aborted && !signal?.aborted) {
|
|
316
|
-
|
|
317
|
-
|
|
354
|
+
timedOut = true;
|
|
355
|
+
matches = [];
|
|
356
|
+
} else {
|
|
357
|
+
throw new ToolAbortError();
|
|
318
358
|
}
|
|
319
|
-
|
|
359
|
+
} else {
|
|
360
|
+
throw error;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (timedOut) {
|
|
365
|
+
// Drain the partial matches accumulated during streaming and return them
|
|
366
|
+
// instead of throwing — empty results after a multi-second wait force the
|
|
367
|
+
// caller to retry blind, which is the worst possible outcome.
|
|
368
|
+
const seen = new Set<string>();
|
|
369
|
+
const partial: string[] = [];
|
|
370
|
+
for (const entry of onUpdateMatches) {
|
|
371
|
+
if (seen.has(entry)) continue;
|
|
372
|
+
seen.add(entry);
|
|
373
|
+
partial.push(entry);
|
|
320
374
|
}
|
|
321
|
-
|
|
375
|
+
const seconds = timeoutMs % 1000 === 0 ? `${timeoutMs / 1000}` : (timeoutMs / 1000).toFixed(1);
|
|
376
|
+
const notice = `find timed out after ${seconds}s; returning ${partial.length} partial matches — increase timeout or narrow pattern`;
|
|
377
|
+
return buildResult(partial, { notice, forceTruncated: true });
|
|
322
378
|
}
|
|
323
379
|
|
|
324
380
|
const relativized: string[] = [];
|
package/src/tools/read.ts
CHANGED
|
@@ -800,10 +800,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
800
800
|
// context (added below if offset is explicit).
|
|
801
801
|
const requestedStart = offset ? Math.max(0, offset - 1) : 0;
|
|
802
802
|
const ignoreResultLimits = options.ignoreResultLimits ?? false;
|
|
803
|
-
const requestedEnd =
|
|
804
|
-
limit !== undefined && !ignoreResultLimits
|
|
805
|
-
? Math.min(requestedStart + limit, allLines.length)
|
|
806
|
-
: allLines.length;
|
|
803
|
+
const requestedEnd = limit !== undefined ? Math.min(requestedStart + limit, allLines.length) : allLines.length;
|
|
807
804
|
// Expand only on sides the user actually constrained: leading context
|
|
808
805
|
// when offset>1, trailing context when a finite limit was set.
|
|
809
806
|
const expanded = expandRangeWithContext(
|
|
@@ -811,7 +808,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
811
808
|
requestedEnd,
|
|
812
809
|
allLines.length,
|
|
813
810
|
offset !== undefined && offset > 1,
|
|
814
|
-
limit !== undefined
|
|
811
|
+
limit !== undefined,
|
|
815
812
|
);
|
|
816
813
|
const startLine = expanded.startLine;
|
|
817
814
|
const endLineExpanded = expanded.endLine;
|
|
@@ -842,7 +839,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
842
839
|
|
|
843
840
|
const endLine = endLineExpanded;
|
|
844
841
|
const selectedContent = allLines.slice(startLine, endLine).join("\n");
|
|
845
|
-
const userLimitedLines = limit !== undefined
|
|
842
|
+
const userLimitedLines = limit !== undefined ? endLine - startLine : undefined;
|
|
846
843
|
const truncation = ignoreResultLimits ? noTruncResult(selectedContent) : truncateHead(selectedContent);
|
|
847
844
|
|
|
848
845
|
const shouldAddHashLines = displayMode.hashLines;
|
package/src/tools/resolve.ts
CHANGED
|
@@ -51,28 +51,43 @@ export function queueResolveHandler(
|
|
|
51
51
|
const forced = session.buildToolChoice?.("resolve");
|
|
52
52
|
if (!queue || !forced || typeof forced === "string") return;
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
});
|
|
54
|
+
const steerReminder = (): void => {
|
|
55
|
+
session.steer?.({
|
|
56
|
+
customType: "resolve-reminder",
|
|
57
|
+
content: [
|
|
58
|
+
"<system-reminder>",
|
|
59
|
+
"This is a preview. Call the `resolve` tool to apply or discard these changes.",
|
|
60
|
+
"</system-reminder>",
|
|
61
|
+
].join("\n"),
|
|
62
|
+
details: { toolName: options.sourceToolName },
|
|
63
|
+
});
|
|
64
|
+
};
|
|
66
65
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
66
|
+
const pushDirective = (): void => {
|
|
67
|
+
queue.pushOnce(forced, {
|
|
68
|
+
label: `pending-action:${options.sourceToolName}`,
|
|
69
|
+
now: true,
|
|
70
|
+
onRejected: () => "requeue",
|
|
71
|
+
onInvoked: async (input: unknown) =>
|
|
72
|
+
runResolveInvocation(input as ResolveParams, {
|
|
73
|
+
sourceToolName: options.sourceToolName,
|
|
74
|
+
label: options.label,
|
|
75
|
+
apply: options.apply,
|
|
76
|
+
reject: options.reject,
|
|
77
|
+
onApplyError: () => {
|
|
78
|
+
// Apply threw (e.g. ast_edit overlapping replacements). Re-push the
|
|
79
|
+
// same directive so the preview remains pending and the model can
|
|
80
|
+
// `discard` or fix-and-retry on the next turn instead of being
|
|
81
|
+
// stranded with no pending action to address.
|
|
82
|
+
pushDirective();
|
|
83
|
+
steerReminder();
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
pushDirective();
|
|
90
|
+
steerReminder();
|
|
76
91
|
}
|
|
77
92
|
|
|
78
93
|
/**
|
|
@@ -89,6 +104,11 @@ export async function runResolveInvocation(
|
|
|
89
104
|
label: string;
|
|
90
105
|
apply(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown>>;
|
|
91
106
|
reject?(reason: string, extra?: Record<string, unknown>): Promise<AgentToolResult<unknown> | undefined>;
|
|
107
|
+
/** Invoked synchronously when `apply()` throws, before the error is rethrown.
|
|
108
|
+
* The queued caller uses this to re-push the resolve directive so the
|
|
109
|
+
* pending preview survives a failed apply (e.g. overlapping ast_edit
|
|
110
|
+
* replacements) and the model can `discard` or fix-and-retry. */
|
|
111
|
+
onApplyError?(error: unknown): void;
|
|
92
112
|
},
|
|
93
113
|
): Promise<AgentToolResult<ResolveToolDetails>> {
|
|
94
114
|
const baseDetails: ResolveToolDetails = {
|
|
@@ -99,7 +119,19 @@ export async function runResolveInvocation(
|
|
|
99
119
|
...(params.extra != null ? { extra: params.extra } : {}),
|
|
100
120
|
};
|
|
101
121
|
if (params.action === "apply") {
|
|
102
|
-
|
|
122
|
+
let result: AgentToolResult<unknown>;
|
|
123
|
+
try {
|
|
124
|
+
result = await options.apply(params.reason, params.extra);
|
|
125
|
+
} catch (error) {
|
|
126
|
+
try {
|
|
127
|
+
options.onApplyError?.(error);
|
|
128
|
+
} catch {
|
|
129
|
+
// Requeue hook must not mask the original apply failure.
|
|
130
|
+
}
|
|
131
|
+
if (error instanceof ToolError) throw error;
|
|
132
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
133
|
+
throw new ToolError(`Apply failed: ${message}`);
|
|
134
|
+
}
|
|
103
135
|
return {
|
|
104
136
|
...result,
|
|
105
137
|
details: {
|
package/src/tools/search.ts
CHANGED
|
@@ -63,6 +63,29 @@ export const SINGLE_FILE_MATCHES = 200;
|
|
|
63
63
|
* pagination headroom so the caller can see total file count. */
|
|
64
64
|
const INTERNAL_TOTAL_CAP = 2000;
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Detect a `,` that is not inside a `{…}` brace expansion. Used to catch
|
|
68
|
+
* `paths: ["a,b"]` mistakes where the caller flattened multiple entries
|
|
69
|
+
* into a single string instead of passing a JSON array of strings.
|
|
70
|
+
*/
|
|
71
|
+
function containsTopLevelComma(entry: string): boolean {
|
|
72
|
+
let depth = 0;
|
|
73
|
+
for (let i = 0; i < entry.length; i++) {
|
|
74
|
+
const ch = entry[i];
|
|
75
|
+
if (ch === "\\" && i + 1 < entry.length) {
|
|
76
|
+
i++;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (ch === "{") depth++;
|
|
80
|
+
else if (ch === "}") {
|
|
81
|
+
if (depth > 0) depth--;
|
|
82
|
+
} else if (ch === "," && depth === 0) {
|
|
83
|
+
return true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
|
|
66
89
|
export interface SearchToolDetails {
|
|
67
90
|
truncation?: TruncationResult;
|
|
68
91
|
fileLimitReached?: number;
|
|
@@ -124,6 +147,11 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
124
147
|
if (normalizedSkip < 0 || !Number.isFinite(normalizedSkip)) {
|
|
125
148
|
throw new ToolError("Skip must be a non-negative number");
|
|
126
149
|
}
|
|
150
|
+
for (const entry of paths) {
|
|
151
|
+
if (containsTopLevelComma(entry)) {
|
|
152
|
+
throw new ToolError('paths is an array — pass ["a", "b"] not ["a,b"]');
|
|
153
|
+
}
|
|
154
|
+
}
|
|
127
155
|
const normalizedContextBefore = this.session.settings.get("search.contextBefore");
|
|
128
156
|
const normalizedContextAfter = this.session.settings.get("search.contextAfter");
|
|
129
157
|
const ignoreCase = i ?? false;
|
|
@@ -148,6 +176,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
|
|
|
148
176
|
missingPaths,
|
|
149
177
|
immutableSourcePaths,
|
|
150
178
|
} = scope;
|
|
179
|
+
if (missingPaths.length > 0 && missingPaths.length === paths.length) {
|
|
180
|
+
throw new ToolError(`Path not found: ${missingPaths.join(", ")}; pass each path as its own array element`);
|
|
181
|
+
}
|
|
151
182
|
const { globFilter } = scope;
|
|
152
183
|
const baseDisplayMode = resolveFileDisplayMode(this.session);
|
|
153
184
|
const immutableDisplayMode = resolveFileDisplayMode(this.session, { immutable: true });
|
package/src/tools/todo-write.ts
CHANGED
|
@@ -150,9 +150,15 @@ function resolveTaskOrError(
|
|
|
150
150
|
}
|
|
151
151
|
const hit = findTaskByContent(phases, content);
|
|
152
152
|
if (!hit) {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
if (/^task-\d+$/.test(content)) {
|
|
154
|
+
errors.push(
|
|
155
|
+
`Task "${content}" not found. Tasks are referenced by content, not by IDs — pass the task's full text from the previous result.`,
|
|
156
|
+
);
|
|
157
|
+
} else {
|
|
158
|
+
const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
|
|
159
|
+
const hint = totalTasks === 0 ? " (todo list is empty — was it replaced or not yet created?)" : "";
|
|
160
|
+
errors.push(`Task "${content}" not found${hint}`);
|
|
161
|
+
}
|
|
156
162
|
}
|
|
157
163
|
return hit;
|
|
158
164
|
}
|
|
@@ -209,7 +215,7 @@ function appendItems(phases: TodoPhase[], entry: TodoOpEntryValue, errors: strin
|
|
|
209
215
|
for (const content of entry.items) {
|
|
210
216
|
if (findTaskByContent(phases, content)) {
|
|
211
217
|
errors.push(`Task "${content}" already exists`);
|
|
212
|
-
|
|
218
|
+
return phases;
|
|
213
219
|
}
|
|
214
220
|
phase.tasks.push({ content, status: "pending" });
|
|
215
221
|
}
|
|
@@ -513,6 +519,7 @@ export class TodoWriteTool implements AgentTool<typeof todoWriteSchema, TodoWrit
|
|
|
513
519
|
return {
|
|
514
520
|
content: [{ type: "text", text: formatSummary(updated, errors) }],
|
|
515
521
|
details: { phases: updated, storage },
|
|
522
|
+
isError: errors.length > 0 ? true : undefined,
|
|
516
523
|
};
|
|
517
524
|
}
|
|
518
525
|
}
|
|
@@ -10,7 +10,7 @@ export interface ToolTimeoutConfig {
|
|
|
10
10
|
export const TOOL_TIMEOUTS = {
|
|
11
11
|
bash: { default: 300, min: 1, max: 3600 },
|
|
12
12
|
eval: { default: 30, min: 1, max: 600 },
|
|
13
|
-
browser: { default: 30, min: 1, max:
|
|
13
|
+
browser: { default: 30, min: 1, max: 300 },
|
|
14
14
|
ssh: { default: 60, min: 1, max: 3600 },
|
|
15
15
|
fetch: { default: 20, min: 1, max: 45 },
|
|
16
16
|
lsp: { default: 20, min: 5, max: 60 },
|
|
@@ -248,31 +248,38 @@ async function downloadTool(tool: ToolName, signal?: AbortSignal): Promise<strin
|
|
|
248
248
|
|
|
249
249
|
// Install a Python package via uv (preferred) or pip
|
|
250
250
|
async function installPythonPackage(pkg: string, signal?: AbortSignal): Promise<boolean> {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
251
|
+
try {
|
|
252
|
+
// Try uv first (faster, better isolation)
|
|
253
|
+
const uv = $which("uv");
|
|
254
|
+
if (uv) {
|
|
255
|
+
const result = await ptree.exec([uv, "tool", "install", pkg], {
|
|
256
|
+
signal,
|
|
257
|
+
allowNonZero: true,
|
|
258
|
+
allowAbort: true,
|
|
259
|
+
stderr: "full",
|
|
260
|
+
});
|
|
261
|
+
if (result.exitCode === 0) return true;
|
|
262
|
+
}
|
|
262
263
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
264
|
+
// Fall back to pip
|
|
265
|
+
const pip = $which("pip3") || $which("pip");
|
|
266
|
+
if (pip) {
|
|
267
|
+
const result = await ptree.exec([pip, "install", "--user", pkg], {
|
|
268
|
+
signal,
|
|
269
|
+
allowNonZero: true,
|
|
270
|
+
allowAbort: true,
|
|
271
|
+
stderr: "full",
|
|
272
|
+
});
|
|
273
|
+
return result.exitCode === 0;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
return false;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
logger.warn(`Failed to install Python package ${pkg}`, {
|
|
279
|
+
error: error instanceof Error ? error.message : String(error),
|
|
271
280
|
});
|
|
272
|
-
return
|
|
281
|
+
return false;
|
|
273
282
|
}
|
|
274
|
-
|
|
275
|
-
return false;
|
|
276
283
|
}
|
|
277
284
|
|
|
278
285
|
// Termux package names for tools
|
|
@@ -425,6 +425,9 @@ async function callCodexSearch(
|
|
|
425
425
|
|
|
426
426
|
const finalAnswer = answerParts.join("\n\n").trim();
|
|
427
427
|
const streamedAnswer = streamedAnswerParts.join("").trim();
|
|
428
|
+
if (isImagePlaceholderAnswer(finalAnswer) && streamedAnswer.length === 0) {
|
|
429
|
+
throw new SearchProviderError("codex", "Codex returned image-only response", 502);
|
|
430
|
+
}
|
|
428
431
|
const answer =
|
|
429
432
|
finalAnswer.length > 0 && !isImagePlaceholderAnswer(finalAnswer)
|
|
430
433
|
? finalAnswer
|
|
@@ -174,6 +174,25 @@ export function findApiKey(): string | null {
|
|
|
174
174
|
return getEnvApiKey("perplexity") ?? null;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Decode a Perplexity JWT's `exp` claim, in ms. Returns `undefined` when the
|
|
179
|
+
* token has no `exp` (which is the common case — Perplexity sessions are
|
|
180
|
+
* server-side and effectively non-expiring from the client's POV).
|
|
181
|
+
*/
|
|
182
|
+
function jwtExpiryMs(token: string): number | undefined {
|
|
183
|
+
const parts = token.split(".");
|
|
184
|
+
if (parts.length !== 3) return undefined;
|
|
185
|
+
const payload = parts[1];
|
|
186
|
+
if (!payload) return undefined;
|
|
187
|
+
try {
|
|
188
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as { exp?: unknown };
|
|
189
|
+
if (typeof decoded.exp !== "number" || !Number.isFinite(decoded.exp)) return undefined;
|
|
190
|
+
return decoded.exp * 1000;
|
|
191
|
+
} catch {
|
|
192
|
+
return undefined;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
177
196
|
async function findOAuthToken(): Promise<string | null> {
|
|
178
197
|
const now = Date.now();
|
|
179
198
|
try {
|
|
@@ -183,7 +202,11 @@ async function findOAuthToken(): Promise<string | null> {
|
|
|
183
202
|
if (record.credential.type !== "oauth") continue;
|
|
184
203
|
const credential = record.credential as PerplexityOAuthCredential;
|
|
185
204
|
if (!credential.access) continue;
|
|
186
|
-
if
|
|
205
|
+
// Trust the JWT's own `exp` claim if it has one; otherwise treat as
|
|
206
|
+
// non-expiring. The stored `expires` field is unreliable: older logins
|
|
207
|
+
// wrote `loginTime + 1h` even though Perplexity JWTs typically lack `exp`.
|
|
208
|
+
const jwtExpiry = jwtExpiryMs(credential.access);
|
|
209
|
+
if (jwtExpiry !== undefined && jwtExpiry <= now + OAUTH_EXPIRY_BUFFER_MS) continue;
|
|
187
210
|
return credential.access;
|
|
188
211
|
}
|
|
189
212
|
} catch {
|