@oh-my-pi/pi-coding-agent 15.7.3 → 15.7.5

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 (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/types/config/settings-schema.d.ts +3 -22
  3. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  4. package/dist/types/extensibility/shared-events.d.ts +2 -2
  5. package/dist/types/internal-urls/local-protocol.d.ts +19 -9
  6. package/dist/types/internal-urls/types.d.ts +14 -0
  7. package/dist/types/lsp/client.d.ts +3 -0
  8. package/dist/types/mcp/manager.d.ts +14 -5
  9. package/dist/types/modes/controllers/command-controller.d.ts +2 -3
  10. package/dist/types/session/agent-session.d.ts +2 -6
  11. package/dist/types/session/shake-types.d.ts +3 -3
  12. package/dist/types/task/repair-args.d.ts +52 -0
  13. package/dist/types/tiny/models.d.ts +0 -14
  14. package/dist/types/tiny/title-client.d.ts +28 -2
  15. package/dist/types/tiny/title-protocol.d.ts +8 -9
  16. package/dist/types/tools/find.d.ts +1 -1
  17. package/dist/types/tools/path-utils.d.ts +7 -0
  18. package/dist/types/tui/output-block.d.ts +7 -7
  19. package/package.json +9 -9
  20. package/scripts/build-binary.ts +0 -1
  21. package/src/cli.ts +59 -0
  22. package/src/config/settings-schema.ts +3 -24
  23. package/src/config/settings.ts +10 -0
  24. package/src/extensibility/custom-tools/types.ts +2 -2
  25. package/src/extensibility/shared-events.ts +2 -2
  26. package/src/internal-urls/docs-index.generated.ts +2 -2
  27. package/src/internal-urls/local-protocol.ts +23 -11
  28. package/src/internal-urls/types.ts +15 -0
  29. package/src/lsp/client.ts +28 -5
  30. package/src/mcp/manager.ts +87 -4
  31. package/src/modes/controllers/command-controller.ts +7 -39
  32. package/src/modes/controllers/event-controller.ts +33 -26
  33. package/src/modes/controllers/mcp-command-controller.ts +1 -1
  34. package/src/prompts/system/project-prompt.md +3 -2
  35. package/src/prompts/system/subagent-system-prompt.md +12 -8
  36. package/src/prompts/system/system-prompt.md +8 -6
  37. package/src/session/agent-session.ts +11 -93
  38. package/src/session/shake-types.ts +4 -5
  39. package/src/slash-commands/builtin-registry.ts +2 -4
  40. package/src/task/executor.ts +14 -4
  41. package/src/task/index.ts +3 -2
  42. package/src/task/repair-args.ts +117 -0
  43. package/src/tiny/models.ts +0 -28
  44. package/src/tiny/title-client.ts +133 -43
  45. package/src/tiny/title-protocol.ts +11 -16
  46. package/src/tiny/worker.ts +6 -61
  47. package/src/tools/ast-edit.ts +3 -0
  48. package/src/tools/ast-grep.ts +3 -0
  49. package/src/tools/find.ts +20 -6
  50. package/src/tools/gh.ts +1 -0
  51. package/src/tools/path-utils.ts +13 -2
  52. package/src/tools/read.ts +1 -0
  53. package/src/tools/search.ts +12 -1
  54. package/src/tui/output-block.ts +37 -75
  55. package/src/utils/git.ts +9 -3
@@ -1,4 +1,6 @@
1
+ import * as path from "node:path";
1
2
  import { $env, isCompiledBinary, logger } from "@oh-my-pi/pi-utils";
3
+ import type { Subprocess } from "bun";
2
4
  import { settings } from "../config/settings";
3
5
  import { tinyModelDeviceSettingToEnv } from "./device";
4
6
  import { tinyModelDtypeSettingToEnv } from "./dtype";
@@ -12,6 +14,14 @@ import {
12
14
  } from "./models";
13
15
  import type { TinyTitleProgressEvent, TinyTitleWorkerInbound, TinyTitleWorkerOutbound } from "./title-protocol";
14
16
 
17
+ /**
18
+ * Abstraction over the tiny-model subprocess. Modelled as a worker interface
19
+ * so existing callers (titles, memory completions, downloads) compose the
20
+ * same way; the runtime implementation is a Bun child process so
21
+ * `onnxruntime-node`'s NAPI finalizer never runs inside the main agent
22
+ * address space — that destructor segfaults Bun on Windows during shutdown
23
+ * (issue #1606).
24
+ */
15
25
  interface WorkerHandle {
16
26
  send(message: TinyTitleWorkerInbound): void;
17
27
  onMessage(handler: (message: TinyTitleWorkerOutbound) => void): () => void;
@@ -31,6 +41,12 @@ export interface TinyTitleDownloadOptions {
31
41
 
32
42
  const SMOKE_TEST_TIMEOUT_MS = 5_000;
33
43
 
44
+ /**
45
+ * Hidden subcommand on the main CLI that boots the tiny-model worker in the
46
+ * spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
47
+ */
48
+ export const TINY_WORKER_ARG = "--tiny-worker";
49
+
34
50
  function readTinyModelSetting(path: "providers.tinyModelDevice" | "providers.tinyModelDtype"): string | undefined {
35
51
  try {
36
52
  const value = settings.get(path);
@@ -66,49 +82,128 @@ export function tinyWorkerEnvOverlay(
66
82
  }
67
83
 
68
84
  /**
69
- * Env handed to the tiny-model worker. The `PI_TINY_DEVICE` / `PI_TINY_DTYPE` env
70
- * vars win; otherwise the persisted `providers.tinyModelDevice` /
71
- * `providers.tinyModelDtype` settings are mapped onto those vars so the worker's
72
- * env-based resolution picks them up. Resolved once at spawn (pipelines are cached).
85
+ * Env handed to the tiny-model subprocess. The `PI_TINY_DEVICE` / `PI_TINY_DTYPE`
86
+ * env vars win; otherwise the persisted `providers.tinyModelDevice` /
87
+ * `providers.tinyModelDtype` settings are mapped onto those vars so the
88
+ * subprocess's env-based resolution picks them up. Resolved once at spawn
89
+ * (pipelines are cached for the lifetime of the subprocess).
73
90
  */
74
- function tinyWorkerEnv(): Record<string, string> | undefined {
91
+ function tinyWorkerEnv(): Record<string, string> {
75
92
  const overlay = tinyWorkerEnvOverlay(
76
93
  $env,
77
94
  readTinyModelSetting("providers.tinyModelDevice"),
78
95
  readTinyModelSetting("providers.tinyModelDtype"),
79
96
  );
80
- if (Object.keys(overlay).length === 0) return undefined;
81
- return { ...($env as Record<string, string>), ...overlay };
97
+ const base = $env as Record<string, string | undefined>;
98
+ const merged: Record<string, string> = {};
99
+ for (const key in base) {
100
+ const value = base[key];
101
+ if (typeof value === "string") merged[key] = value;
102
+ }
103
+ for (const key in overlay) merged[key] = overlay[key];
104
+ return merged;
82
105
  }
83
106
 
84
- export function createTinyTitleWorker(): Worker {
85
- const env = tinyWorkerEnv();
86
- const options: WorkerOptions = env ? { type: "module", env } : { type: "module" };
87
- return isCompiledBinary()
88
- ? new Worker("./packages/coding-agent/src/tiny/worker.ts", options)
89
- : new Worker(new URL("./worker.ts", import.meta.url).href, options);
107
+ /**
108
+ * Resolve the argv used to relaunch the agent CLI into tiny-worker mode. In a
109
+ * compiled binary the entry point is the binary itself; in dev/source the
110
+ * spawned `bun` needs the absolute path to `cli.ts` so it can resolve module
111
+ * imports against the on-disk source tree.
112
+ */
113
+ function tinyWorkerSpawnCmd(): string[] {
114
+ if (isCompiledBinary()) return [process.execPath, TINY_WORKER_ARG];
115
+ const cliPath = path.resolve(import.meta.dir, "..", "cli.ts");
116
+ return [process.execPath, cliPath, TINY_WORKER_ARG];
117
+ }
118
+
119
+ interface SpawnedSubprocess {
120
+ proc: Subprocess<"ignore", "inherit", "inherit">;
121
+ inbound: Set<(message: TinyTitleWorkerOutbound) => void>;
122
+ errors: Set<(error: Error) => void>;
123
+ /**
124
+ * Flipped to `true` by {@link wrapSubprocess}'s `terminate()` right
125
+ * before it SIGKILLs the child so `onExit` can distinguish the
126
+ * expected hard-kill from a crash/OOM/external signal. Only the
127
+ * latter is surfaced as a worker error.
128
+ */
129
+ intentionalExit: { value: boolean };
90
130
  }
91
131
 
92
- function wrapBunWorker(worker: Worker): WorkerHandle {
93
- (worker as Worker & { unref?: () => void }).unref?.();
132
+ /**
133
+ * Spawn the tiny-model worker as a subprocess. Exported for tests and the
134
+ * smoke probe; production callers go through {@link spawnTinyTitleWorker}
135
+ * which wraps the result in a {@link WorkerHandle}.
136
+ */
137
+ export function createTinyTitleSubprocess(): SpawnedSubprocess {
138
+ const inbound = new Set<(message: TinyTitleWorkerOutbound) => void>();
139
+ const errors = new Set<(error: Error) => void>();
140
+ const intentionalExit = { value: false };
141
+ const proc = Bun.spawn({
142
+ cmd: tinyWorkerSpawnCmd(),
143
+ env: tinyWorkerEnv(),
144
+ stdin: "ignore",
145
+ stdout: "inherit",
146
+ stderr: "inherit",
147
+ serialization: "advanced",
148
+ windowsHide: true,
149
+ ipc(message) {
150
+ for (const handler of inbound) handler(message as TinyTitleWorkerOutbound);
151
+ },
152
+ onExit(_proc, exitCode, signalCode) {
153
+ // Clean exit. The child only exits via SIGKILL in practice, but
154
+ // treat code 0 as a no-op for symmetry.
155
+ if (exitCode === 0) return;
156
+ // `exitCode === null` + non-null `signalCode` covers both the
157
+ // expected SIGKILL from `terminate()` AND external kills
158
+ // (SIGSEGV from a native crash, SIGKILL from the OOM killer, an
159
+ // operator `kill -9`, etc.). Swallow only the expected one;
160
+ // every other signal exit is a real worker death that must
161
+ // fault every in-flight request so callers don't await forever.
162
+ if (exitCode === null && intentionalExit.value) return;
163
+ const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
164
+ const err = new Error(`tiny model subprocess exited with ${reason}`);
165
+ for (const handler of errors) handler(err);
166
+ },
167
+ });
168
+ // Don't keep the parent event loop alive on account of an idle worker; the
169
+ // agent dispose path calls `terminate()` explicitly when shutting down.
170
+ proc.unref();
171
+ return { proc, inbound, errors, intentionalExit };
172
+ }
173
+
174
+ function wrapSubprocess({ proc, inbound, errors, intentionalExit }: SpawnedSubprocess): WorkerHandle {
94
175
  return {
95
176
  send(message) {
96
- worker.postMessage(message);
177
+ try {
178
+ proc.send(message);
179
+ } catch (error) {
180
+ logger.debug("tiny-title: send to subprocess failed", {
181
+ error: error instanceof Error ? error.message : String(error),
182
+ });
183
+ }
97
184
  },
98
185
  onMessage(handler) {
99
- const wrap = (event: MessageEvent): void => handler(event.data as TinyTitleWorkerOutbound);
100
- worker.addEventListener("message", wrap);
101
- return () => worker.removeEventListener("message", wrap);
186
+ inbound.add(handler);
187
+ return () => inbound.delete(handler);
102
188
  },
103
189
  onError(handler) {
104
- const wrap = (event: ErrorEvent): void => {
105
- handler(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
106
- };
107
- worker.addEventListener("error", wrap);
108
- return () => worker.removeEventListener("error", wrap);
190
+ errors.add(handler);
191
+ return () => errors.delete(handler);
109
192
  },
110
193
  async terminate() {
111
- worker.terminate();
194
+ // SIGKILL: the whole point of the subprocess isolation is that the
195
+ // parent never runs `onnxruntime-node`'s NAPI finalizer. A polite
196
+ // SIGTERM lets the subprocess try to clean up, which is exactly the
197
+ // codepath that crashes Bun on Windows. Hard-kill instead — the
198
+ // model lives in process memory and the OS reclaims everything.
199
+ // Flip the intentional-exit flag *before* killing so `onExit` can
200
+ // tell this apart from a crash or external SIGKILL.
201
+ intentionalExit.value = true;
202
+ try {
203
+ proc.kill("SIGKILL");
204
+ } catch {
205
+ // Already gone.
206
+ }
112
207
  },
113
208
  };
114
209
  }
@@ -126,10 +221,6 @@ function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
126
221
  emit({ type: "pong", id: message.id });
127
222
  return;
128
223
  }
129
- if (message.type === "close") {
130
- emit({ type: "closed" });
131
- return;
132
- }
133
224
  emit({ type: "error", id: message.id, error: errorMessage });
134
225
  });
135
226
  },
@@ -148,9 +239,9 @@ function spawnInlineUnavailableWorker(error: unknown): WorkerHandle {
148
239
 
149
240
  function spawnTinyTitleWorker(): WorkerHandle {
150
241
  try {
151
- return wrapBunWorker(createTinyTitleWorker());
242
+ return wrapSubprocess(createTinyTitleSubprocess());
152
243
  } catch (error) {
153
- logger.warn("Tiny title Worker spawn failed; local titles disabled", {
244
+ logger.warn("Tiny title worker spawn failed; local titles disabled", {
154
245
  error: error instanceof Error ? error.message : String(error),
155
246
  });
156
247
  return spawnInlineUnavailableWorker(error);
@@ -293,9 +384,9 @@ export class TinyTitleClient {
293
384
  }
294
385
  this.#pending.clear();
295
386
  try {
296
- worker?.send({ type: "close" });
387
+ await worker?.terminate();
297
388
  } catch {
298
- // Worker may already be gone.
389
+ // Already gone.
299
390
  }
300
391
  }
301
392
 
@@ -317,7 +408,6 @@ export class TinyTitleClient {
317
408
  this.#emitProgress(message.event);
318
409
  return;
319
410
  }
320
- if (message.type === "closed") return;
321
411
  if (message.type === "pong") return;
322
412
 
323
413
  const pending = this.#pending.get(message.id);
@@ -371,25 +461,25 @@ export async function smokeTestTinyTitleWorker({
371
461
  }: {
372
462
  timeoutMs?: number;
373
463
  } = {}): Promise<void> {
374
- const worker = createTinyTitleWorker();
464
+ const handle = wrapSubprocess(createTinyTitleSubprocess());
375
465
  const { promise, resolve, reject } = Promise.withResolvers<void>();
376
466
  const timer = setTimeout(() => reject(new Error(`tiny title worker did not pong within ${timeoutMs}ms`)), timeoutMs);
377
- worker.onmessage = (event: MessageEvent<TinyTitleWorkerOutbound>) => {
378
- const message = event.data;
467
+ const unsubscribeMessage = handle.onMessage(message => {
379
468
  if (message.type === "pong") {
380
469
  resolve();
381
470
  return;
382
471
  }
472
+ if (message.type === "log") return;
383
473
  reject(new Error(`tiny title worker: expected pong, got ${JSON.stringify(message)}`));
384
- };
385
- worker.onerror = (event: ErrorEvent) => {
386
- reject(event.error instanceof Error ? event.error : new Error(event.message || "tiny title worker error"));
387
- };
474
+ });
475
+ const unsubscribeError = handle.onError(reject);
388
476
  try {
389
- worker.postMessage({ type: "ping", id: "smoke" } satisfies TinyTitleWorkerInbound);
477
+ handle.send({ type: "ping", id: "smoke" } satisfies TinyTitleWorkerInbound);
390
478
  await promise;
391
479
  } finally {
392
480
  clearTimeout(timer);
393
- worker.terminate();
481
+ unsubscribeMessage();
482
+ unsubscribeError();
483
+ await handle.terminate();
394
484
  }
395
485
  }
@@ -30,19 +30,8 @@ export interface TinyTitleProgressEvent {
30
30
  export type TinyTitleWorkerInbound =
31
31
  | { type: "ping"; id: string }
32
32
  | { type: "generate"; id: string; modelKey: TinyTitleLocalModelKey; message: string }
33
- | {
34
- type: "complete";
35
- id: string;
36
- modelKey: TinyLocalModelKey;
37
- prompt: string;
38
- maxTokens?: number;
39
- /** Optional assistant-turn prefix appended after the generation prompt to pin output format. */
40
- prefill?: string;
41
- /** Optional literal stop string; generation halts once it appears in the decoded tail. */
42
- stop?: string;
43
- }
44
- | { type: "download"; id: string; modelKey: TinyLocalModelKey }
45
- | { type: "close" };
33
+ | { type: "complete"; id: string; modelKey: TinyLocalModelKey; prompt: string; maxTokens?: number }
34
+ | { type: "download"; id: string; modelKey: TinyLocalModelKey };
46
35
 
47
36
  export type TinyTitleWorkerOutbound =
48
37
  | { type: "pong"; id: string }
@@ -51,11 +40,17 @@ export type TinyTitleWorkerOutbound =
51
40
  | { type: "downloaded"; id: string }
52
41
  | { type: "error"; id: string; error: string }
53
42
  | { type: "progress"; id: string; event: TinyTitleProgressEvent }
54
- | { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> }
55
- | { type: "closed" };
43
+ | { type: "log"; level: "debug" | "warn" | "error"; msg: string; meta?: Record<string, unknown> };
56
44
 
45
+ /**
46
+ * Wire transport between the parent (`TinyTitleClient`) and the tiny-model
47
+ * subprocess. The parent owns the subprocess lifecycle (graceful work, hard
48
+ * kill on shutdown); the protocol therefore carries no explicit close
49
+ * handshake — once the parent decides to terminate, it signals the OS to
50
+ * reap the child so `onnxruntime-node`'s NAPI finalizer never runs in any
51
+ * shared address space. See `title-client.ts` for the spawn/kill glue.
52
+ */
57
53
  export interface TinyTitleTransport {
58
54
  send(message: TinyTitleWorkerOutbound): void;
59
55
  onMessage(handler: (message: TinyTitleWorkerInbound) => void): () => void;
60
- close(): void;
61
56
  }
@@ -1,7 +1,6 @@
1
1
  import * as fs from "node:fs/promises";
2
2
  import { createRequire } from "node:module";
3
3
  import * as path from "node:path";
4
- import { parentPort } from "node:worker_threads";
5
4
  import type {
6
5
  ProgressInfo,
7
6
  TextGenerationPipeline,
@@ -20,12 +19,7 @@ import {
20
19
  type TinyTitleLocalModelSpec,
21
20
  } from "./models";
22
21
  import { formatTitleUserMessage, normalizeGeneratedTitle } from "./text";
23
- import type {
24
- TinyTitleProgressEvent,
25
- TinyTitleTransport,
26
- TinyTitleWorkerInbound,
27
- TinyTitleWorkerOutbound,
28
- } from "./title-protocol";
22
+ import type { TinyTitleProgressEvent, TinyTitleTransport, TinyTitleWorkerInbound } from "./title-protocol";
29
23
 
30
24
  const TITLE_PREFILL = "<title>";
31
25
  const TITLE_CLOSE = "</title>";
@@ -461,15 +455,14 @@ async function generateTitle(
461
455
  return extractTinyTitle(output[0]?.generated_text ?? "");
462
456
  }
463
457
 
464
- function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string, prefill?: string): string {
458
+ function buildCompletionPrompt(generator: TextGenerationPipeline, promptText: string): string {
465
459
  const chat = [{ role: "user", content: promptText }];
466
460
  const chatTemplateOptions = {
467
461
  add_generation_prompt: true,
468
462
  tokenize: false,
469
463
  enable_thinking: false,
470
464
  };
471
- const base = generator.tokenizer.apply_chat_template(chat, chatTemplateOptions) as string;
472
- return prefill ? `${base}${prefill}` : base;
465
+ return `${generator.tokenizer.apply_chat_template(chat, chatTemplateOptions)}`;
473
466
  }
474
467
 
475
468
  /**
@@ -484,37 +477,18 @@ async function generateCompletion(
484
477
  modelKey: TinyLocalModelKey,
485
478
  promptText: string,
486
479
  maxTokens: number | undefined,
487
- prefill?: string,
488
- stop?: string,
489
480
  ): Promise<string | null> {
490
481
  const generator = await loadPipeline(modelKey, transport, requestId);
491
- const text = buildCompletionPrompt(generator, promptText, prefill);
482
+ const text = buildCompletionPrompt(generator, promptText);
492
483
  const requested = maxTokens ?? MEMORY_COMPLETION_MAX_NEW_TOKENS;
493
484
  const maxNewTokens = Math.min(Math.max(1, requested), MEMORY_COMPLETION_MAX_NEW_TOKENS);
494
- const transformers = stop ? await loadTransformers(transport, requestId, modelKey) : undefined;
495
485
  const output = (await generator(text, {
496
486
  max_new_tokens: maxNewTokens,
497
487
  do_sample: false,
498
488
  return_full_text: false,
499
- ...(transformers && stop
500
- ? { stopping_criteria: createStopOnTextCriteria(transformers, generator.tokenizer, stop) }
501
- : {}),
502
489
  })) as TextGenerationStringOutput;
503
- const generated = output[0]?.generated_text ?? "";
504
- // Re-attach the forced prefix so the caller's parser sees the full assistant turn,
505
- // including the opening tag it pinned via `prefill`.
506
- const full = `${prefill ?? ""}${generated}`.trim();
507
- return full === "" ? null : full;
508
- }
509
-
510
- function releasePipelines(): void {
511
- // Intentionally NOT calling `pipeline.dispose()`. transformers.js disposes the
512
- // underlying onnxruntime InferenceSession, freeing native memory that Bun's
513
- // worker/NAPI teardown then frees a second time — a double-free that aborts the
514
- // process on quit ("malloc: pointer being freed was not allocated" /
515
- // "NAPI FATAL ERROR"). The worker is torn down immediately after `close`, so the
516
- // OS reclaims the model memory regardless; skipping dispose avoids the crash.
517
- pipelines.clear();
490
+ const generated = (output[0]?.generated_text ?? "").trim();
491
+ return generated === "" ? null : generated;
518
492
  }
519
493
 
520
494
  function enqueueRequest(
@@ -548,8 +522,6 @@ async function handleQueuedRequest(
548
522
  request.modelKey,
549
523
  request.prompt,
550
524
  request.maxTokens,
551
- request.prefill,
552
- request.stop,
553
525
  );
554
526
  transport.send({ type: "completion", id: request.id, text });
555
527
  return;
@@ -567,33 +539,6 @@ export function startTinyTitleWorker(transport: TinyTitleTransport): void {
567
539
  transport.send({ type: "pong", id: message.id });
568
540
  return;
569
541
  }
570
- if (message.type === "close") {
571
- releasePipelines();
572
- transport.send({ type: "closed" });
573
- transport.close();
574
- return;
575
- }
576
542
  enqueueRequest(transport, message);
577
543
  });
578
544
  }
579
-
580
- if (!parentPort) throw new Error("tiny-title-worker: missing parentPort");
581
-
582
- const port = parentPort;
583
- const transport: TinyTitleTransport = {
584
- send: (message: TinyTitleWorkerOutbound) => port.postMessage(message),
585
- onMessage: handler => {
586
- const wrap = (data: unknown): void => handler(data as TinyTitleWorkerInbound);
587
- port.on("message", wrap);
588
- return () => port.off("message", wrap);
589
- },
590
- close: () => {
591
- try {
592
- port.close();
593
- } catch {
594
- // Already closed.
595
- }
596
- },
597
- };
598
-
599
- startTinyTitleWorker(transport);
@@ -230,6 +230,9 @@ export class AstEditTool implements AgentTool<typeof astEditSchema, AstEditToolD
230
230
  rawPaths: params.paths,
231
231
  cwd: this.session.cwd,
232
232
  internalUrlAction: "rewrite",
233
+ settings: this.session.settings,
234
+ signal,
235
+ localProtocolOptions: this.session.localProtocolOptions,
233
236
  });
234
237
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
235
238
 
@@ -155,6 +155,9 @@ export class AstGrepTool implements AgentTool<typeof astGrepSchema, AstGrepToolD
155
155
  rawPaths: params.paths,
156
156
  cwd: this.session.cwd,
157
157
  internalUrlAction: "search",
158
+ settings: this.session.settings,
159
+ signal,
160
+ localProtocolOptions: this.session.localProtocolOptions,
158
161
  });
159
162
  const { searchPath: resolvedSearchPath, scopePath, isDirectory, multiTargets, globFilter } = scope;
160
163
 
package/src/tools/find.ts CHANGED
@@ -192,7 +192,12 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
192
192
  if (hasGlobPathChars(rawPattern)) {
193
193
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPattern}`);
194
194
  }
195
- const resource = await internalRouter.resolve(rawPattern);
195
+ const resource = await internalRouter.resolve(rawPattern, {
196
+ cwd: this.session.cwd,
197
+ settings: this.session.settings,
198
+ signal,
199
+ localProtocolOptions: this.session.localProtocolOptions,
200
+ });
196
201
  if (!resource.sourcePath) {
197
202
  throw new ToolError(`Cannot find internal URL without a backing file: ${rawPattern}`);
198
203
  }
@@ -443,10 +448,14 @@ export class FindTool implements AgentTool<typeof findSchema, FindToolDetails> {
443
448
  // =============================================================================
444
449
 
445
450
  interface FindRenderArgs {
446
- paths?: string[];
451
+ paths?: string | string[];
447
452
  limit?: number;
448
453
  }
449
454
 
455
+ function formatFindRenderPaths(paths: FindRenderArgs["paths"]): string | undefined {
456
+ return Array.isArray(paths) ? paths.join(", ") : paths;
457
+ }
458
+
450
459
  const COLLAPSED_LIST_LIMIT = PREVIEW_LIMITS.COLLAPSED_ITEMS;
451
460
 
452
461
  export const findToolRenderer = {
@@ -456,7 +465,7 @@ export const findToolRenderer = {
456
465
  if (args.limit !== undefined) meta.push(`limit:${args.limit}`);
457
466
 
458
467
  const text = renderStatusLine(
459
- { icon: "pending", title: "Find", description: args.paths?.join(", ") || "*", meta },
468
+ { icon: "pending", title: "Find", description: formatFindRenderPaths(args.paths) || "*", meta },
460
469
  uiTheme,
461
470
  );
462
471
  return new Text(text, 0, 0);
@@ -493,7 +502,7 @@ export const findToolRenderer = {
493
502
  {
494
503
  icon: "success",
495
504
  title: "Find",
496
- description: args?.paths?.join(", "),
505
+ description: formatFindRenderPaths(args?.paths),
497
506
  meta: [formatCount("file", lines.length)],
498
507
  },
499
508
  uiTheme,
@@ -528,7 +537,7 @@ export const findToolRenderer = {
528
537
 
529
538
  if (fileCount === 0) {
530
539
  const header = renderStatusLine(
531
- { icon: "warning", title: "Find", description: args?.paths?.join(", "), meta: ["0 files"] },
540
+ { icon: "warning", title: "Find", description: formatFindRenderPaths(args?.paths), meta: ["0 files"] },
532
541
  uiTheme,
533
542
  );
534
543
  const lines = [header, formatEmptyMessage("No files found", uiTheme)];
@@ -539,7 +548,12 @@ export const findToolRenderer = {
539
548
  if (details?.scopePath) meta.push(`in ${details.scopePath}`);
540
549
  if (truncated) meta.push(uiTheme.fg("warning", "truncated"));
541
550
  const header = renderStatusLine(
542
- { icon: truncated ? "warning" : "success", title: "Find", description: args?.paths?.join(", "), meta },
551
+ {
552
+ icon: truncated ? "warning" : "success",
553
+ title: "Find",
554
+ description: formatFindRenderPaths(args?.paths),
555
+ meta,
556
+ },
543
557
  uiTheme,
544
558
  );
545
559
 
package/src/tools/gh.ts CHANGED
@@ -75,6 +75,7 @@ const GH_PR_FIELDS = [
75
75
  "labels",
76
76
  "mergeStateStatus",
77
77
  "number",
78
+ "reviews",
78
79
  "reviewDecision",
79
80
  "state",
80
81
  "title",
@@ -3,7 +3,7 @@ import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import * as url from "node:url";
5
5
  import { isEnoent } from "@oh-my-pi/pi-utils";
6
- import { InternalUrlRouter } from "../internal-urls";
6
+ import { InternalUrlRouter, type LocalProtocolOptions } from "../internal-urls";
7
7
  import { ToolError } from "./tool-errors";
8
8
 
9
9
  const UNICODE_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
@@ -740,6 +740,12 @@ export interface ToolScopeOptions {
740
740
  surfaceExactFilePaths?: boolean;
741
741
  /** Extra hint appended to "Path not found" when stat fails and the user supplied multiple paths. */
742
742
  multipathStatHint?: string;
743
+ /** Calling session's settings — forwarded to the internal-URL router so caller-aware handlers (issue://, pr://) honor it. */
744
+ settings?: unknown;
745
+ /** Caller's abort signal — forwarded to the internal-URL router. */
746
+ signal?: AbortSignal;
747
+ /** Calling session's `local://` root mapping — pins resolutions to the calling session. */
748
+ localProtocolOptions?: LocalProtocolOptions;
743
749
  }
744
750
 
745
751
  export interface ToolScopeResolution {
@@ -778,7 +784,12 @@ export async function resolveToolSearchScope(opts: ToolScopeOptions): Promise<To
778
784
  if (hasGlobPathChars(rawPath)) {
779
785
  throw new ToolError(`Glob patterns are not supported for internal URLs: ${rawPath}`);
780
786
  }
781
- const resource = await internalRouter.resolve(rawPath);
787
+ const resource = await internalRouter.resolve(rawPath, {
788
+ cwd,
789
+ settings: opts.settings,
790
+ signal: opts.signal,
791
+ localProtocolOptions: opts.localProtocolOptions,
792
+ });
782
793
  if (!resource.sourcePath) {
783
794
  throw new ToolError(`Cannot ${internalUrlAction} internal URL without a backing file: ${rawPath}`);
784
795
  }
package/src/tools/read.ts CHANGED
@@ -2141,6 +2141,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2141
2141
  cwd: this.session.cwd,
2142
2142
  settings: this.session.settings,
2143
2143
  signal,
2144
+ localProtocolOptions: this.session.localProtocolOptions,
2144
2145
  });
2145
2146
  const details: ReadToolDetails = { resolvedPath: resource.sourcePath, contentType: resource.contentType };
2146
2147
 
@@ -10,6 +10,7 @@ import { prompt, untilAborted } from "@oh-my-pi/pi-utils";
10
10
  import * as z from "zod/v4";
11
11
  import { recordFileSnapshot } from "../edit/file-snapshot-store";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
+ import type { LocalProtocolOptions } from "../internal-urls/local-protocol";
13
14
  import { InternalUrlRouter } from "../internal-urls/router";
14
15
  import type { InternalResource, ResolveContext } from "../internal-urls/types";
15
16
  import type { Theme } from "../modes/theme/theme";
@@ -543,6 +544,7 @@ async function resolveInternalSearchInputs(opts: {
543
544
  settings: unknown;
544
545
  signal?: AbortSignal;
545
546
  archiveDisplayMap: ReadonlyMap<string, string>;
547
+ localProtocolOptions?: LocalProtocolOptions;
546
548
  }): Promise<InternalSearchInputResolution> {
547
549
  const internalRouter = InternalUrlRouter.instance();
548
550
  const paths = opts.resolvedPaths.slice();
@@ -551,7 +553,12 @@ async function resolveInternalSearchInputs(opts: {
551
553
  const virtualInputIndexes = new Set<number>();
552
554
  const immutableSourcePaths = new Set<string>();
553
555
  let virtualScopePath: string | undefined;
554
- const context: ResolveContext = { cwd: opts.cwd, settings: opts.settings, signal: opts.signal };
556
+ const context: ResolveContext = {
557
+ cwd: opts.cwd,
558
+ settings: opts.settings,
559
+ signal: opts.signal,
560
+ localProtocolOptions: opts.localProtocolOptions,
561
+ };
555
562
 
556
563
  for (let idx = 0; idx < paths.length; idx++) {
557
564
  const rawPath = paths[idx];
@@ -674,6 +681,7 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
674
681
  settings: this.session.settings,
675
682
  signal,
676
683
  archiveDisplayMap,
684
+ localProtocolOptions: this.session.localProtocolOptions,
677
685
  });
678
686
  const searchablePaths = internalResolution.paths;
679
687
  const { virtualResources, virtualPathSet, virtualInputIndexes } = internalResolution;
@@ -738,6 +746,9 @@ export class SearchTool implements AgentTool<typeof searchSchema, SearchToolDeta
738
746
  trackImmutableSources: true,
739
747
  surfaceExactFilePaths: true,
740
748
  multipathStatHint: " (`paths` entries must each exist relative to cwd)",
749
+ settings: this.session.settings,
750
+ signal,
751
+ localProtocolOptions: this.session.localProtocolOptions,
741
752
  });
742
753
  searchPath = scope.searchPath;
743
754
  isDirectory = scope.isDirectory;