@oh-my-pi/pi-coding-agent 14.0.5 → 14.1.1

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 (101) hide show
  1. package/CHANGELOG.md +120 -0
  2. package/package.json +8 -8
  3. package/src/async/index.ts +1 -0
  4. package/src/async/job-manager.ts +43 -10
  5. package/src/async/support.ts +5 -0
  6. package/src/cli/list-models.ts +96 -57
  7. package/src/commit/agentic/tools/analyze-file.ts +1 -2
  8. package/src/commit/model-selection.ts +16 -13
  9. package/src/config/mcp-schema.json +1 -1
  10. package/src/config/model-equivalence.ts +675 -0
  11. package/src/config/model-registry.ts +242 -45
  12. package/src/config/model-resolver.ts +282 -65
  13. package/src/config/settings-schema.ts +27 -3
  14. package/src/config/settings.ts +1 -1
  15. package/src/cursor.ts +64 -23
  16. package/src/edit/index.ts +254 -89
  17. package/src/edit/modes/chunk.ts +336 -57
  18. package/src/edit/modes/hashline.ts +51 -26
  19. package/src/edit/modes/patch.ts +16 -10
  20. package/src/edit/modes/replace.ts +15 -7
  21. package/src/edit/renderer.ts +248 -94
  22. package/src/export/html/template.css +82 -0
  23. package/src/export/html/template.generated.ts +1 -1
  24. package/src/export/html/template.js +614 -97
  25. package/src/extensibility/custom-tools/types.ts +0 -3
  26. package/src/extensibility/extensions/loader.ts +16 -0
  27. package/src/extensibility/extensions/runner.ts +2 -7
  28. package/src/extensibility/extensions/types.ts +8 -4
  29. package/src/internal-urls/docs-index.generated.ts +4 -4
  30. package/src/internal-urls/jobs-protocol.ts +2 -1
  31. package/src/ipy/executor.ts +447 -52
  32. package/src/ipy/kernel.ts +39 -13
  33. package/src/lsp/client.ts +55 -1
  34. package/src/lsp/index.ts +8 -0
  35. package/src/lsp/types.ts +6 -0
  36. package/src/main.ts +6 -2
  37. package/src/memories/index.ts +7 -6
  38. package/src/modes/acp/acp-agent.ts +4 -1
  39. package/src/modes/components/bash-execution.ts +16 -4
  40. package/src/modes/components/model-selector.ts +221 -64
  41. package/src/modes/components/status-line/presets.ts +17 -6
  42. package/src/modes/components/status-line/segments.ts +15 -0
  43. package/src/modes/components/status-line-segment-editor.ts +1 -0
  44. package/src/modes/components/status-line.ts +7 -1
  45. package/src/modes/components/tool-execution.ts +145 -75
  46. package/src/modes/controllers/command-controller.ts +42 -1
  47. package/src/modes/controllers/event-controller.ts +4 -1
  48. package/src/modes/controllers/extension-ui-controller.ts +28 -5
  49. package/src/modes/controllers/input-controller.ts +9 -3
  50. package/src/modes/controllers/selector-controller.ts +17 -6
  51. package/src/modes/interactive-mode.ts +19 -3
  52. package/src/modes/print-mode.ts +13 -4
  53. package/src/modes/prompt-action-autocomplete.ts +3 -5
  54. package/src/modes/rpc/rpc-mode.ts +8 -2
  55. package/src/modes/shared.ts +2 -2
  56. package/src/modes/types.ts +1 -0
  57. package/src/modes/utils/ui-helpers.ts +1 -0
  58. package/src/prompts/system/system-prompt.md +5 -1
  59. package/src/prompts/tools/bash.md +16 -1
  60. package/src/prompts/tools/cancel-job.md +1 -1
  61. package/src/prompts/tools/chunk-edit.md +191 -163
  62. package/src/prompts/tools/hashline.md +11 -11
  63. package/src/prompts/tools/patch.md +10 -5
  64. package/src/prompts/tools/{await.md → poll.md} +1 -1
  65. package/src/prompts/tools/read-chunk.md +12 -3
  66. package/src/prompts/tools/read.md +9 -0
  67. package/src/prompts/tools/task.md +2 -2
  68. package/src/prompts/tools/vim.md +98 -0
  69. package/src/prompts/tools/write.md +1 -0
  70. package/src/sdk.ts +758 -725
  71. package/src/session/agent-session.ts +187 -40
  72. package/src/session/session-manager.ts +50 -4
  73. package/src/slash-commands/builtin-registry.ts +17 -0
  74. package/src/task/executor.ts +9 -5
  75. package/src/task/index.ts +3 -5
  76. package/src/task/types.ts +2 -2
  77. package/src/tools/bash.ts +240 -57
  78. package/src/tools/cancel-job.ts +2 -1
  79. package/src/tools/find.ts +5 -2
  80. package/src/tools/grep.ts +77 -8
  81. package/src/tools/index.ts +48 -19
  82. package/src/tools/inspect-image.ts +1 -1
  83. package/src/tools/{await-tool.ts → poll-tool.ts} +38 -31
  84. package/src/tools/python.ts +293 -278
  85. package/src/tools/read.ts +218 -1
  86. package/src/tools/sqlite-reader.ts +623 -0
  87. package/src/tools/submit-result.ts +5 -2
  88. package/src/tools/todo-write.ts +8 -2
  89. package/src/tools/vim.ts +966 -0
  90. package/src/tools/write.ts +187 -1
  91. package/src/utils/commit-message-generator.ts +1 -0
  92. package/src/utils/edit-mode.ts +2 -1
  93. package/src/utils/git.ts +24 -1
  94. package/src/utils/session-color.ts +55 -0
  95. package/src/utils/title-generator.ts +16 -7
  96. package/src/vim/buffer.ts +309 -0
  97. package/src/vim/commands.ts +382 -0
  98. package/src/vim/engine.ts +2426 -0
  99. package/src/vim/parser.ts +151 -0
  100. package/src/vim/render.ts +252 -0
  101. package/src/vim/types.ts +197 -0
package/src/task/index.ts CHANGED
@@ -530,8 +530,8 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
530
530
  });
531
531
  const thinkingLevelOverride = effectiveAgent.thinkingLevel;
532
532
 
533
- // Output schema priority: agent frontmatter > params > inherited from parent session
534
- const effectiveOutputSchema = effectiveAgent.output ?? outputSchema ?? this.session.outputSchema;
533
+ // Output schema priority: caller params > agent frontmatter > inherited from parent session
534
+ const effectiveOutputSchema = outputSchema ?? effectiveAgent.output ?? this.session.outputSchema;
535
535
 
536
536
  // Handle empty or missing tasks
537
537
  if (!params.tasks || params.tasks.length === 0) {
@@ -787,7 +787,6 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
787
787
  },
788
788
  authStorage: this.session.authStorage,
789
789
  modelRegistry: this.session.modelRegistry,
790
- searchDb: this.session.searchDb,
791
790
  settings: this.session.settings,
792
791
  mcpManager: this.session.mcpManager,
793
792
  contextFiles,
@@ -841,7 +840,6 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
841
840
  },
842
841
  authStorage: this.session.authStorage,
843
842
  modelRegistry: this.session.modelRegistry,
844
- searchDb: this.session.searchDb,
845
843
  settings: this.session.settings,
846
844
  mcpManager: this.session.mcpManager,
847
845
  contextFiles,
@@ -1116,8 +1114,8 @@ export class TaskTool implements AgentTool<TaskSchema, TaskToolDetails, Theme> {
1116
1114
  }
1117
1115
 
1118
1116
  // Build final output - match plugin format
1119
- const successCount = results.filter(r => r.exitCode === 0 && !r.error).length;
1120
1117
  const cancelledCount = results.filter(r => r.aborted).length;
1118
+ const successCount = results.filter(r => r.exitCode === 0 && !r.error && !r.aborted).length;
1121
1119
  const totalDuration = Date.now() - startTime;
1122
1120
 
1123
1121
  const summaries = results.map(r => {
package/src/task/types.ts CHANGED
@@ -82,9 +82,9 @@ const createTaskSchema = (options: { isolationEnabled: boolean }) => {
82
82
  }),
83
83
  ),
84
84
  schema: Type.Optional(
85
- Type.Record(Type.String(), Type.Unknown(), {
85
+ Type.String({
86
86
  description:
87
- "JTD schema defining expected response structure. Use typed properties. Output format belongs here — never in context or assignment.",
87
+ "JSON-encoded JTD schema defining expected response structure. Output format belongs here — never in context or assignment.",
88
88
  }),
89
89
  ),
90
90
  tasks: Type.Array(taskItemSchema, {
package/src/tools/bash.ts CHANGED
@@ -28,6 +28,7 @@ import { clampTimeout } from "./tool-timeouts";
28
28
  export const BASH_DEFAULT_PREVIEW_LINES = 10;
29
29
 
30
30
  const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
31
+ const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
31
32
 
32
33
  const bashSchemaBase = Type.Object({
33
34
  command: Type.String({ description: "Command to execute" }),
@@ -72,6 +73,7 @@ export interface BashToolInput {
72
73
 
73
74
  export interface BashToolDetails {
74
75
  meta?: OutputMeta;
76
+ timeoutSeconds?: number;
75
77
  async?: {
76
78
  state: "running" | "completed" | "failed";
77
79
  jobId: string;
@@ -81,6 +83,24 @@ export interface BashToolDetails {
81
83
 
82
84
  export interface BashToolOptions {}
83
85
 
86
+ type ManagedBashJobCompletion =
87
+ | {
88
+ kind: "completed";
89
+ result: AgentToolResult<BashToolDetails>;
90
+ }
91
+ | {
92
+ kind: "failed";
93
+ error: unknown;
94
+ };
95
+
96
+ interface ManagedBashJobHandle {
97
+ jobId: string;
98
+ label: string;
99
+ completion: Promise<ManagedBashJobCompletion>;
100
+ getLatestText: () => string;
101
+ setBackgrounded: (backgrounded: boolean) => void;
102
+ }
103
+
84
104
  function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
85
105
  return result.output || "";
86
106
  }
@@ -212,12 +232,23 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
212
232
  readonly concurrency = "exclusive";
213
233
  readonly strict = true;
214
234
  readonly #asyncEnabled: boolean;
235
+ readonly #autoBackgroundEnabled: boolean;
236
+ readonly #autoBackgroundThresholdMs: number;
215
237
 
216
238
  constructor(private readonly session: ToolSession) {
217
239
  this.#asyncEnabled = this.session.settings.get("async.enabled");
240
+ this.#autoBackgroundEnabled = this.session.settings.get("bash.autoBackground.enabled");
241
+ this.#autoBackgroundThresholdMs = Math.max(
242
+ 0,
243
+ Math.floor(
244
+ this.session.settings.get("bash.autoBackground.thresholdMs") ?? DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS,
245
+ ),
246
+ );
218
247
  this.parameters = this.#asyncEnabled ? bashSchemaWithAsync : bashSchemaBase;
219
248
  this.description = prompt.render(bashDescription, {
220
249
  asyncEnabled: this.#asyncEnabled,
250
+ autoBackgroundEnabled: this.#autoBackgroundEnabled,
251
+ autoBackgroundThresholdSeconds: Math.max(0, Math.floor(this.#autoBackgroundThresholdMs / 1000)),
221
252
  hasAstGrep: this.session.settings.get("astGrep.enabled"),
222
253
  hasAstEdit: this.session.settings.get("astEdit.enabled"),
223
254
  hasGrep: this.session.settings.get("grep.enabled"),
@@ -253,6 +284,165 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
253
284
  return outputText;
254
285
  }
255
286
 
287
+ #buildCompletedResult(
288
+ result: BashResult | BashInteractiveResult,
289
+ timeoutSec: number,
290
+ headLines?: number,
291
+ tailLines?: number,
292
+ ): AgentToolResult<BashToolDetails> {
293
+ const outputText = this.#formatResultOutput(result, headLines, tailLines);
294
+ const details: BashToolDetails = { timeoutSeconds: timeoutSec };
295
+ const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
296
+ this.#buildResultText(result, timeoutSec, outputText);
297
+ return resultBuilder.done();
298
+ }
299
+
300
+ #buildBackgroundStartResult(
301
+ jobId: string,
302
+ label: string,
303
+ previewText: string,
304
+ timeoutSec: number,
305
+ ): AgentToolResult<BashToolDetails> {
306
+ const details: BashToolDetails = {
307
+ timeoutSeconds: timeoutSec,
308
+ async: { state: "running", jobId, type: "bash" },
309
+ };
310
+ const lines: string[] = [];
311
+ const trimmedPreview = previewText.trimEnd();
312
+ if (trimmedPreview.length > 0) {
313
+ lines.push(trimmedPreview, "");
314
+ }
315
+ lines.push(`Background job ${jobId} started: ${label}`);
316
+ lines.push("Result will be delivered automatically when complete.");
317
+ lines.push(`Use \`poll\`, \`read jobs://${jobId}\`, or \`cancel_job\` if needed.`);
318
+ return {
319
+ content: [{ type: "text", text: lines.join("\n") }],
320
+ details,
321
+ };
322
+ }
323
+
324
+ #extractTextResult(result: AgentToolResult<BashToolDetails>): string {
325
+ return result.content.find(block => block.type === "text")?.text ?? "";
326
+ }
327
+
328
+ #startManagedBashJob(options: {
329
+ command: string;
330
+ commandCwd: string;
331
+ timeoutMs: number;
332
+ timeoutSec: number;
333
+ headLines?: number;
334
+ tailLines?: number;
335
+ resolvedEnv?: Record<string, string>;
336
+ onUpdate?: AgentToolUpdateCallback<BashToolDetails>;
337
+ startBackgrounded: boolean;
338
+ }): ManagedBashJobHandle {
339
+ const manager = this.session.asyncJobManager;
340
+ if (!manager) {
341
+ throw new ToolError("Background job manager unavailable for this session.");
342
+ }
343
+
344
+ const label = options.command.length > 120 ? `${options.command.slice(0, 117)}...` : options.command;
345
+ let latestText = "";
346
+ let backgrounded = options.startBackgrounded;
347
+ const completion = Promise.withResolvers<ManagedBashJobCompletion>();
348
+
349
+ const jobId = manager.register(
350
+ "bash",
351
+ label,
352
+ async ({ jobId, signal: runSignal, reportProgress }) => {
353
+ const { path: artifactPath, id: artifactId } = (await this.session.allocateOutputArtifact?.("bash")) ?? {};
354
+ const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
355
+ try {
356
+ const result = await executeBash(options.command, {
357
+ cwd: options.commandCwd,
358
+ sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
359
+ timeout: options.timeoutMs,
360
+ signal: runSignal,
361
+ env: options.resolvedEnv,
362
+ artifactPath,
363
+ artifactId,
364
+ onChunk: chunk => {
365
+ tailBuffer.append(chunk);
366
+ latestText = tailBuffer.text();
367
+ void reportProgress(latestText, { async: { state: "running", jobId, type: "bash" } });
368
+ },
369
+ });
370
+ const finalResult = this.#buildCompletedResult(
371
+ result,
372
+ options.timeoutSec,
373
+ options.headLines,
374
+ options.tailLines,
375
+ );
376
+ const finalText = this.#extractTextResult(finalResult);
377
+ latestText = finalText;
378
+ completion.resolve({ kind: "completed", result: finalResult });
379
+ await reportProgress(finalText, { async: { state: "completed", jobId, type: "bash" } });
380
+ return finalText;
381
+ } catch (error) {
382
+ const message = error instanceof Error ? error.message : String(error);
383
+ latestText = message;
384
+ completion.resolve({ kind: "failed", error });
385
+ await reportProgress(message, { async: { state: "failed", jobId, type: "bash" } });
386
+ throw error;
387
+ }
388
+ },
389
+ {
390
+ onProgress: async (text, details) => {
391
+ latestText = text;
392
+ await options.onUpdate?.({
393
+ content: [{ type: "text", text }],
394
+ details: backgrounded ? ((details ?? {}) as BashToolDetails) : {},
395
+ });
396
+ },
397
+ },
398
+ );
399
+
400
+ return {
401
+ jobId,
402
+ label,
403
+ completion: completion.promise,
404
+ getLatestText: () => latestText,
405
+ setBackgrounded: (nextBackgrounded: boolean) => {
406
+ backgrounded = nextBackgrounded;
407
+ },
408
+ };
409
+ }
410
+
411
+ async #waitForManagedBashJob(
412
+ job: ManagedBashJobHandle,
413
+ thresholdMs: number,
414
+ signal?: AbortSignal,
415
+ ): Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }> {
416
+ if (signal?.aborted) {
417
+ return { kind: "aborted" };
418
+ }
419
+
420
+ const waiters: Array<Promise<ManagedBashJobCompletion | { kind: "running" } | { kind: "aborted" }>> = [
421
+ job.completion,
422
+ Bun.sleep(thresholdMs).then(() => ({ kind: "running" as const })),
423
+ ];
424
+
425
+ if (!signal) {
426
+ return await Promise.race(waiters);
427
+ }
428
+
429
+ const { promise: abortedPromise, resolve: resolveAborted } = Promise.withResolvers<{ kind: "aborted" }>();
430
+ const onAbort = () => resolveAborted({ kind: "aborted" });
431
+ signal.addEventListener("abort", onAbort, { once: true });
432
+ waiters.push(abortedPromise);
433
+ try {
434
+ return await Promise.race(waiters);
435
+ } finally {
436
+ signal.removeEventListener("abort", onAbort);
437
+ }
438
+ }
439
+
440
+ #resolveAutoBackgroundWaitMs(timeoutMs: number): number {
441
+ if (this.#autoBackgroundThresholdMs <= 0) return 0;
442
+ const timeoutBufferMs = 1_000;
443
+ return Math.max(0, Math.min(this.#autoBackgroundThresholdMs, timeoutMs - timeoutBufferMs));
444
+ }
445
+
256
446
  async execute(
257
447
  _toolCallId: string,
258
448
  {
@@ -345,52 +535,56 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
345
535
  const timeoutMs = timeoutSec * 1000;
346
536
 
347
537
  if (asyncRequested) {
348
- const manager = this.session.asyncJobManager;
349
- if (!manager) {
538
+ if (!this.session.asyncJobManager) {
350
539
  throw new ToolError("Async job manager unavailable for this session.");
351
540
  }
352
- const label = command.length > 120 ? `${command.slice(0, 117)}...` : command;
353
- const tailBuffer = new TailBuffer(DEFAULT_MAX_BYTES);
354
- const jobId = manager.register(
355
- "bash",
356
- label,
357
- async ({ jobId, signal: runSignal, reportProgress }) => {
358
- const { path: artifactPath, id: artifactId } =
359
- (await this.session.allocateOutputArtifact?.("bash")) ?? {};
360
- try {
361
- const result = await executeBash(command, {
362
- cwd: commandCwd,
363
- sessionKey: `${this.session.getSessionId?.() ?? ""}:async:${jobId}`,
364
- timeout: timeoutMs,
365
- signal: runSignal,
366
- env: resolvedEnv,
367
- artifactPath,
368
- artifactId,
369
- onChunk: chunk => {
370
- tailBuffer.append(chunk);
371
- void reportProgress(tailBuffer.text(), { async: { state: "running", jobId, type: "bash" } });
372
- },
373
- });
374
- const outputText = this.#formatResultOutput(result, headLines, tailLines);
375
- const finalText = this.#buildResultText(result, timeoutSec, outputText);
376
- await reportProgress(finalText, { async: { state: "completed", jobId, type: "bash" } });
377
- return finalText;
378
- } catch (error) {
379
- const message = error instanceof Error ? error.message : String(error);
380
- await reportProgress(message, { async: { state: "failed", jobId, type: "bash" } });
381
- throw error;
382
- }
383
- },
384
- {
385
- onProgress: (text, details) => {
386
- onUpdate?.({ content: [{ type: "text", text }], details: details ?? {} });
387
- },
388
- },
389
- );
390
- return {
391
- content: [{ type: "text", text: `Background job ${jobId} started: ${label}` }],
392
- details: { async: { state: "running", jobId, type: "bash" } },
393
- };
541
+ const job = this.#startManagedBashJob({
542
+ command,
543
+ commandCwd,
544
+ timeoutMs,
545
+ timeoutSec,
546
+ headLines,
547
+ tailLines,
548
+ resolvedEnv,
549
+ onUpdate,
550
+ startBackgrounded: true,
551
+ });
552
+ return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
553
+ }
554
+
555
+ if (this.#autoBackgroundEnabled && !pty && this.session.asyncJobManager) {
556
+ const autoBackgroundWaitMs = this.#resolveAutoBackgroundWaitMs(timeoutMs);
557
+ const startBackgrounded = autoBackgroundWaitMs === 0;
558
+ const job = this.#startManagedBashJob({
559
+ command,
560
+ commandCwd,
561
+ timeoutMs,
562
+ timeoutSec,
563
+ headLines,
564
+ tailLines,
565
+ resolvedEnv,
566
+ onUpdate,
567
+ startBackgrounded,
568
+ });
569
+ if (startBackgrounded) {
570
+ return this.#buildBackgroundStartResult(job.jobId, job.label, "", timeoutSec);
571
+ }
572
+ const waitResult = await this.#waitForManagedBashJob(job, autoBackgroundWaitMs, signal);
573
+ if (waitResult.kind === "completed") {
574
+ this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
575
+ return waitResult.result;
576
+ }
577
+ if (waitResult.kind === "failed") {
578
+ this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
579
+ throw waitResult.error;
580
+ }
581
+ if (waitResult.kind === "aborted") {
582
+ this.session.asyncJobManager.cancel(job.jobId);
583
+ this.session.asyncJobManager.acknowledgeDeliveries([job.jobId]);
584
+ throw new ToolAbortError(job.getLatestText() || "Command aborted");
585
+ }
586
+ job.setBackgrounded(true);
587
+ return this.#buildBackgroundStartResult(job.jobId, job.label, job.getLatestText(), timeoutSec);
394
588
  }
395
589
 
396
590
  // Track output for streaming updates (tail only)
@@ -437,18 +631,7 @@ export class BashTool implements AgentTool<BashToolSchema, BashToolDetails> {
437
631
  if (isInteractiveResult(result) && result.timedOut) {
438
632
  throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
439
633
  }
440
-
441
- const outputText = this.#formatResultOutput(result, headLines, tailLines);
442
- const details: BashToolDetails = {};
443
- const resultBuilder = toolResult(details).text(outputText).truncationFromSummary(result, { direction: "tail" });
444
- if (result.exitCode === undefined) {
445
- throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
446
- }
447
- if (result.exitCode !== 0 && result.exitCode !== undefined) {
448
- throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
449
- }
450
-
451
- return resultBuilder.done();
634
+ return this.#buildCompletedResult(result, timeoutSec, headLines, tailLines);
452
635
  }
453
636
  }
454
637
 
@@ -524,7 +707,7 @@ export const bashToolRenderer = {
524
707
  const showingFullOutput = expanded && renderContext?.isFullOutput === true;
525
708
 
526
709
  // Build truncation warning
527
- const timeoutSeconds = renderContext?.timeout;
710
+ const timeoutSeconds = details?.timeoutSeconds ?? renderContext?.timeout;
528
711
  const timeoutLine =
529
712
  typeof timeoutSeconds === "number"
530
713
  ? uiTheme.fg(
@@ -1,6 +1,7 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
2
  import { prompt } from "@oh-my-pi/pi-utils";
3
3
  import { type Static, Type } from "@sinclair/typebox";
4
+ import { isBackgroundJobSupportEnabled } from "../async";
4
5
  import cancelJobDescription from "../prompts/tools/cancel-job.md" with { type: "text" };
5
6
  import type { ToolSession } from "./index";
6
7
 
@@ -27,7 +28,7 @@ export class CancelJobTool implements AgentTool<typeof cancelJobSchema, CancelJo
27
28
  }
28
29
 
29
30
  static createIf(session: ToolSession): CancelJobTool | null {
30
- if (!session.settings.get("async.enabled")) return null;
31
+ if (!isBackgroundJobSupportEnabled(session.settings)) return null;
31
32
  return new CancelJobTool(session);
32
33
  }
33
34
 
package/src/tools/find.ts CHANGED
@@ -244,17 +244,20 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
244
244
  maxResults: effectiveLimit,
245
245
  sortByMtime: true,
246
246
  gitignore: useGitignore,
247
+ signal: combinedSignal,
247
248
  },
248
249
  onMatch,
249
- this.session.searchDb,
250
250
  ),
251
251
  );
252
252
 
253
253
  try {
254
254
  let result = await doGlob(true);
255
- if (result.matches.length === 0) {
255
+ if (result.matches.length === 0 && !timeoutSignal.aborted) {
256
256
  result = await doGlob(false);
257
257
  }
258
+ // Sort by mtime descending (most recent first) in JS instead of native.
259
+ // This allows native glob to early-terminate at maxResults.
260
+ result.matches.sort((a, b) => (b.mtime ?? 0) - (a.mtime ?? 0));
258
261
  matches = result.matches;
259
262
  } catch (error) {
260
263
  if (error instanceof Error && error.name === "AbortError") {
package/src/tools/grep.ts CHANGED
@@ -7,7 +7,7 @@ import { Text } from "@oh-my-pi/pi-tui";
7
7
  import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
8
8
  import { type Static, Type } from "@sinclair/typebox";
9
9
  import { computeLineHash } from "../edit/line-hash";
10
- import { formatChunkedGrepLine } from "../edit/modes/chunk";
10
+ import { type ChunkedGrepMatch, describeChunkedGrepMatch } from "../edit/modes/chunk";
11
11
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
12
12
  import { getLanguageFromPath, type Theme } from "../modes/theme/theme";
13
13
  import grepDescription from "../prompts/tools/grep.md" with { type: "text" };
@@ -162,7 +162,8 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
162
162
  const stat = await Bun.file(searchPath).stat();
163
163
  isDirectory = stat.isDirectory();
164
164
  } catch {
165
- throw new ToolError(`Path not found: ${scopePath}`);
165
+ const hint = scopePath.includes(",") ? ` (comma-separated paths must each exist relative to cwd)` : "";
166
+ throw new ToolError(`Path not found: ${scopePath}${hint}`);
166
167
  }
167
168
 
168
169
  const effectiveOutputMode = GrepOutputMode.Content;
@@ -191,7 +192,6 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
191
192
  mode: effectiveOutputMode,
192
193
  },
193
194
  undefined,
194
- this.session.searchDb,
195
195
  );
196
196
  } catch (err) {
197
197
  if (err instanceof Error && err.message.startsWith("regex parse error")) {
@@ -274,13 +274,11 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
274
274
  matchesByFile.get(relativePath)!.push(match);
275
275
  }
276
276
  if (chunkMode) {
277
- const annotatedLines = await Promise.all(
277
+ const annotatedMatches = await Promise.all(
278
278
  selectedMatches.map(match => {
279
279
  const relativePath = match.path.startsWith("/") ? match.path.slice(1) : match.path;
280
280
  const absoluteFilePath = isDirectory ? path.join(searchPath, relativePath) : searchPath;
281
- const displayPath = formatPath(match.path);
282
- fileMatchCounts.set(displayPath, (fileMatchCounts.get(displayPath) ?? 0) + 1);
283
- return formatChunkedGrepLine({
281
+ return describeChunkedGrepMatch({
284
282
  filePath: absoluteFilePath,
285
283
  lineNumber: match.lineNumber,
286
284
  line: match.line,
@@ -289,7 +287,78 @@ export class GrepTool implements AgentTool<typeof grepSchema, GrepToolDetails> {
289
287
  });
290
288
  }),
291
289
  );
292
- const rawOutput = annotatedLines.join("\n");
290
+ const chunkMatchesByFile = new Map<string, ChunkedGrepMatch[]>();
291
+ for (const match of annotatedMatches) {
292
+ recordFile(match.displayPath);
293
+ if (!chunkMatchesByFile.has(match.displayPath)) {
294
+ chunkMatchesByFile.set(match.displayPath, []);
295
+ }
296
+ chunkMatchesByFile.get(match.displayPath)!.push(match);
297
+ }
298
+ const renderChunkedMatchesForFile = (relativePath: string) => {
299
+ const fileMatches = chunkMatchesByFile.get(relativePath) ?? [];
300
+ if (fileMatches.length === 0) {
301
+ return;
302
+ }
303
+ const lineWidth = fileMatches[0]?.fileLineCount.toString().length ?? 1;
304
+ const matchesByChunk = new Map<string, ChunkedGrepMatch[]>();
305
+ for (const match of fileMatches) {
306
+ const chunkKey = match.chunkPath ?? "";
307
+ if (!matchesByChunk.has(chunkKey)) {
308
+ matchesByChunk.set(chunkKey, []);
309
+ }
310
+ matchesByChunk.get(chunkKey)!.push(match);
311
+ }
312
+ for (const [chunkPath, chunkMatches] of matchesByChunk) {
313
+ if (chunkPath) {
314
+ const chunkChecksum = chunkMatches[0]?.chunkChecksum;
315
+ const dashes = "-".repeat(chunkPath.split(".").length - 1);
316
+ const anchor = chunkChecksum
317
+ ? `${dashes}@${chunkPath}#${chunkChecksum}`
318
+ : `${dashes}@${chunkPath}`;
319
+ outputLines.push(anchor);
320
+ }
321
+ for (const match of chunkMatches) {
322
+ outputLines.push(` ${match.lineNumber.toString().padStart(lineWidth, " ")} |${match.line}`);
323
+ fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
324
+ }
325
+ }
326
+ };
327
+ if (isDirectory) {
328
+ const filesByDirectory = new Map<string, string[]>();
329
+ for (const relativePath of fileList) {
330
+ const directory = path.dirname(relativePath).replace(/\\/g, "/");
331
+ if (!filesByDirectory.has(directory)) {
332
+ filesByDirectory.set(directory, []);
333
+ }
334
+ filesByDirectory.get(directory)!.push(relativePath);
335
+ }
336
+ for (const [directory, directoryFiles] of filesByDirectory) {
337
+ if (directory === ".") {
338
+ for (const relativePath of directoryFiles) {
339
+ if (outputLines.length > 0) {
340
+ outputLines.push("");
341
+ }
342
+ outputLines.push(`# ${path.basename(relativePath)}`);
343
+ renderChunkedMatchesForFile(relativePath);
344
+ }
345
+ continue;
346
+ }
347
+ if (outputLines.length > 0) {
348
+ outputLines.push("");
349
+ }
350
+ outputLines.push(`# ${directory}`);
351
+ for (const relativePath of directoryFiles) {
352
+ outputLines.push(`## └─ ${path.basename(relativePath)}`);
353
+ renderChunkedMatchesForFile(relativePath);
354
+ }
355
+ }
356
+ } else {
357
+ for (const relativePath of fileList) {
358
+ renderChunkedMatchesForFile(relativePath);
359
+ }
360
+ }
361
+ const rawOutput = outputLines.join("\n");
293
362
  const truncation = truncateHead(rawOutput, { maxLines: Number.MAX_SAFE_INTEGER });
294
363
  const truncated = Boolean(matchLimitReached || result.limitReached || truncation.truncated);
295
364
  const details: GrepToolDetails = {