@oh-my-pi/pi-coding-agent 15.1.6 → 15.1.7

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 (50) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/types/config/settings-schema.d.ts +15 -7
  3. package/dist/types/hashline/hash.d.ts +4 -4
  4. package/dist/types/hashline/recovery.d.ts +5 -0
  5. package/dist/types/lsp/edits.d.ts +8 -1
  6. package/dist/types/session/agent-session.d.ts +16 -0
  7. package/dist/types/session/client-bridge.d.ts +1 -0
  8. package/dist/types/tools/find.d.ts +4 -0
  9. package/dist/types/tools/resolve.d.ts +5 -0
  10. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  11. package/package.json +7 -7
  12. package/src/config/settings-schema.ts +22 -7
  13. package/src/dap/session.ts +58 -5
  14. package/src/edit/modes/patch.ts +46 -0
  15. package/src/eval/js/context-manager.ts +11 -7
  16. package/src/eval/js/shared/rewrite-imports.ts +21 -9
  17. package/src/eval/js/shared/runtime.ts +2 -1
  18. package/src/hashline/hash.ts +11 -8
  19. package/src/hashline/parser.ts +23 -6
  20. package/src/hashline/recovery.ts +44 -3
  21. package/src/lsp/edits.ts +92 -38
  22. package/src/lsp/index.ts +110 -7
  23. package/src/lsp/utils.ts +13 -0
  24. package/src/modes/acp/acp-client-bridge.ts +1 -0
  25. package/src/modes/components/status-line/segments.ts +1 -1
  26. package/src/prompts/tools/bash.md +14 -0
  27. package/src/prompts/tools/debug.md +4 -1
  28. package/src/prompts/tools/find.md +10 -0
  29. package/src/prompts/tools/hashline.md +5 -3
  30. package/src/prompts/tools/resolve.md +1 -1
  31. package/src/prompts/tools/search.md +2 -1
  32. package/src/prompts/tools/task.md +4 -0
  33. package/src/prompts/tools/todo-write.md +2 -0
  34. package/src/session/agent-session.ts +116 -8
  35. package/src/session/client-bridge.ts +1 -0
  36. package/src/slash-commands/builtin-registry.ts +1 -1
  37. package/src/task/index.ts +33 -5
  38. package/src/task/render.ts +4 -1
  39. package/src/tools/browser/tab-supervisor.ts +23 -3
  40. package/src/tools/browser/tab-worker.ts +4 -2
  41. package/src/tools/browser.ts +1 -1
  42. package/src/tools/debug.ts +19 -2
  43. package/src/tools/find.ts +80 -24
  44. package/src/tools/read.ts +3 -6
  45. package/src/tools/resolve.ts +54 -22
  46. package/src/tools/search.ts +31 -0
  47. package/src/tools/todo-write.ts +11 -4
  48. package/src/tools/tool-timeouts.ts +1 -1
  49. package/src/utils/tools-manager.ts +29 -22
  50. package/src/web/search/providers/codex.ts +3 -0
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 GLOB_TIMEOUT_MS = 5000;
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 timeoutSignal = AbortSignal.timeout(GLOB_TIMEOUT_MS);
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 = (files: string[]): AgentToolResult<FindToolDetails> => {
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: false,
223
+ truncated: forceTruncated,
188
224
  missingPaths: missingPaths.length > 0 ? missingPaths : undefined,
189
225
  };
190
- const text = missingPathsNote
191
- ? `No files found matching pattern\n${missingPathsNote}`
192
- : "No files found matching pattern";
193
- return toolResult(details).text(text).done();
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 rawOutput = missingPathsNote ? `${baseOutput}\n\n${missingPathsNote}` : baseOutput;
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 = onUpdate
282
- ? (err: Error | null, match: natives.GlobMatch | null) => {
283
- if (err || signal?.aborted || !match?.path) return;
284
- const relativePath = formatMatchPath(match.path, match.fileType);
285
- onUpdateMatches.push(relativePath);
286
- emitUpdate();
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(true);
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
- const timeoutSeconds = Math.max(1, Math.round(GLOB_TIMEOUT_MS / 1000));
317
- throw new ToolError(`find timed out after ${timeoutSeconds}s`);
354
+ timedOut = true;
355
+ matches = [];
356
+ } else {
357
+ throw new ToolAbortError();
318
358
  }
319
- throw new ToolAbortError();
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
- throw error;
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 && !ignoreResultLimits,
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 && !ignoreResultLimits ? endLine - startLine : 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;
@@ -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
- queue.pushOnce(forced, {
55
- label: `pending-action:${options.sourceToolName}`,
56
- now: true,
57
- onRejected: () => "requeue",
58
- onInvoked: async (input: unknown) =>
59
- runResolveInvocation(input as ResolveParams, {
60
- sourceToolName: options.sourceToolName,
61
- label: options.label,
62
- apply: options.apply,
63
- reject: options.reject,
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
- session.steer?.({
68
- customType: "resolve-reminder",
69
- content: [
70
- "<system-reminder>",
71
- "This is a preview. Call the `resolve` tool to apply or discard these changes.",
72
- "</system-reminder>",
73
- ].join("\n"),
74
- details: { toolName: options.sourceToolName },
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
- const result = await options.apply(params.reason, params.extra);
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: {
@@ -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 });
@@ -150,9 +150,15 @@ function resolveTaskOrError(
150
150
  }
151
151
  const hit = findTaskByContent(phases, content);
152
152
  if (!hit) {
153
- const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
154
- const hint = totalTasks === 0 ? " (todo list is empty — was it replaced or not yet created?)" : "";
155
- errors.push(`Task "${content}" not found${hint}`);
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
- continue;
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: 30 },
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
- // Try uv first (faster, better isolation)
252
- const uv = $which("uv");
253
- if (uv) {
254
- const result = await ptree.exec(["uv", "tool", "install", pkg], {
255
- signal,
256
- allowNonZero: true,
257
- allowAbort: true,
258
- stderr: "full",
259
- });
260
- if (result.exitCode === 0) return true;
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
- // Fall back to pip
264
- const pip = $which("pip3") || $which("pip");
265
- if (pip) {
266
- const result = await ptree.exec(["pip", "install", "--user", pkg], {
267
- signal,
268
- allowNonZero: true,
269
- allowAbort: true,
270
- stderr: "full",
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 result.exitCode === 0;
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