@oh-my-pi/pi-coding-agent 16.2.13 → 16.3.0

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 (166) hide show
  1. package/CHANGELOG.md +108 -7
  2. package/dist/cli.js +6033 -5982
  3. package/dist/types/advisor/config.d.ts +4 -2
  4. package/dist/types/collab/host.d.ts +16 -0
  5. package/dist/types/collab/protocol.d.ts +9 -3
  6. package/dist/types/commit/model-selection.d.ts +5 -0
  7. package/dist/types/config/model-resolver.d.ts +12 -10
  8. package/dist/types/config/settings-schema.d.ts +28 -5
  9. package/dist/types/edit/modes/patch.d.ts +11 -0
  10. package/dist/types/eval/agent-bridge.d.ts +5 -1
  11. package/dist/types/exec/bash-executor.d.ts +1 -0
  12. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
  13. package/dist/types/extensibility/skills.d.ts +2 -1
  14. package/dist/types/mnemopi/state.d.ts +12 -0
  15. package/dist/types/modes/components/agent-hub.d.ts +9 -5
  16. package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
  17. package/dist/types/modes/components/usage-row.d.ts +1 -1
  18. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  19. package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
  20. package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
  21. package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
  22. package/dist/types/modes/interactive-mode.d.ts +12 -7
  23. package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
  24. package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
  25. package/dist/types/modes/session-teardown.d.ts +63 -0
  26. package/dist/types/modes/session-teardown.test.d.ts +1 -0
  27. package/dist/types/modes/theme/theme.d.ts +9 -4
  28. package/dist/types/modes/types.d.ts +2 -3
  29. package/dist/types/modes/utils/copy-targets.d.ts +2 -0
  30. package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
  31. package/dist/types/sdk.d.ts +9 -0
  32. package/dist/types/session/agent-session.d.ts +46 -13
  33. package/dist/types/session/exit-diagnostics.d.ts +48 -0
  34. package/dist/types/session/session-loader.d.ts +5 -0
  35. package/dist/types/task/executor.d.ts +12 -5
  36. package/dist/types/task/parallel.d.ts +1 -0
  37. package/dist/types/task/render.test.d.ts +1 -0
  38. package/dist/types/thinking.d.ts +2 -0
  39. package/dist/types/tools/checkpoint.d.ts +8 -0
  40. package/dist/types/tools/eval-render.d.ts +1 -0
  41. package/dist/types/tools/fetch.d.ts +11 -1
  42. package/dist/types/tools/index.d.ts +3 -1
  43. package/dist/types/tools/irc.d.ts +1 -0
  44. package/dist/types/tools/output-meta.d.ts +7 -0
  45. package/dist/types/tools/output-schema-validator.d.ts +7 -4
  46. package/dist/types/tools/path-utils.d.ts +16 -0
  47. package/dist/types/tools/render-utils.d.ts +4 -1
  48. package/dist/types/utils/git.d.ts +47 -2
  49. package/dist/types/web/search/provider.d.ts +7 -0
  50. package/dist/types/web/search/types.d.ts +1 -1
  51. package/package.json +12 -12
  52. package/src/advisor/config.ts +4 -2
  53. package/src/cli/bench-cli.ts +7 -2
  54. package/src/collab/guest.ts +94 -5
  55. package/src/collab/host.ts +80 -3
  56. package/src/collab/protocol.ts +9 -2
  57. package/src/commit/model-selection.ts +8 -2
  58. package/src/config/keybindings.ts +3 -1
  59. package/src/config/model-discovery.ts +66 -2
  60. package/src/config/model-registry.ts +7 -3
  61. package/src/config/model-resolver.ts +51 -23
  62. package/src/config/settings-schema.ts +44 -14
  63. package/src/edit/hashline/diff.ts +13 -2
  64. package/src/edit/index.ts +58 -8
  65. package/src/edit/modes/patch.ts +53 -18
  66. package/src/edit/streaming.ts +7 -6
  67. package/src/eval/__tests__/agent-bridge.test.ts +57 -1
  68. package/src/eval/agent-bridge.ts +15 -5
  69. package/src/exec/bash-executor.ts +7 -12
  70. package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
  71. package/src/extensibility/skills.ts +29 -6
  72. package/src/goals/guided-setup.ts +4 -3
  73. package/src/internal-urls/docs-index.generated.txt +1 -1
  74. package/src/lsp/client.ts +11 -14
  75. package/src/main.ts +38 -10
  76. package/src/mnemopi/state.ts +43 -7
  77. package/src/modes/acp/acp-agent.ts +1 -1
  78. package/src/modes/components/agent-hub.ts +10 -2
  79. package/src/modes/components/agent-transcript-viewer.ts +39 -7
  80. package/src/modes/components/assistant-message.ts +16 -10
  81. package/src/modes/components/chat-transcript-builder.ts +11 -1
  82. package/src/modes/components/custom-editor.test.ts +20 -0
  83. package/src/modes/components/hook-selector.ts +44 -25
  84. package/src/modes/components/model-selector.ts +1 -1
  85. package/src/modes/components/status-line/component.test.ts +44 -0
  86. package/src/modes/components/status-line/component.ts +9 -1
  87. package/src/modes/components/status-line/segments.ts +3 -3
  88. package/src/modes/components/usage-row.ts +16 -2
  89. package/src/modes/controllers/command-controller.ts +6 -21
  90. package/src/modes/controllers/event-controller.ts +11 -9
  91. package/src/modes/controllers/extension-ui-controller.ts +84 -3
  92. package/src/modes/controllers/input-controller.ts +16 -13
  93. package/src/modes/controllers/selector-controller.ts +3 -8
  94. package/src/modes/controllers/streaming-reveal.ts +75 -53
  95. package/src/modes/controllers/tool-args-reveal.ts +340 -10
  96. package/src/modes/interactive-mode.ts +122 -79
  97. package/src/modes/rpc/rpc-client.ts +21 -0
  98. package/src/modes/rpc/rpc-mode.ts +197 -46
  99. package/src/modes/session-teardown.test.ts +219 -0
  100. package/src/modes/session-teardown.ts +82 -0
  101. package/src/modes/setup-wizard/scenes/theme.ts +2 -2
  102. package/src/modes/skill-command.ts +7 -20
  103. package/src/modes/theme/theme.ts +29 -15
  104. package/src/modes/types.ts +2 -3
  105. package/src/modes/utils/copy-targets.ts +12 -0
  106. package/src/modes/utils/ui-helpers.ts +19 -2
  107. package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
  108. package/src/prompts/advisor/system.md +1 -1
  109. package/src/prompts/agents/tester.md +6 -2
  110. package/src/prompts/skills/autoload.md +8 -0
  111. package/src/prompts/skills/user-invocation.md +11 -0
  112. package/src/prompts/steering/parent-irc.md +5 -0
  113. package/src/prompts/system/irc-incoming.md +2 -0
  114. package/src/prompts/system/mid-run-todo-nudge.md +3 -0
  115. package/src/prompts/system/plan-mode-approved.md +7 -10
  116. package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
  117. package/src/prompts/system/plan-mode-reference.md +3 -4
  118. package/src/prompts/system/rewind-report.md +6 -0
  119. package/src/prompts/system/subagent-system-prompt.md +3 -0
  120. package/src/prompts/tools/irc.md +2 -1
  121. package/src/prompts/tools/job.md +2 -2
  122. package/src/prompts/tools/rewind.md +3 -2
  123. package/src/prompts/tools/task.md +4 -0
  124. package/src/sdk.ts +18 -4
  125. package/src/session/agent-session.ts +660 -114
  126. package/src/session/exit-diagnostics.ts +202 -0
  127. package/src/session/session-context.ts +25 -13
  128. package/src/session/session-loader.ts +58 -25
  129. package/src/session/session-manager.ts +0 -1
  130. package/src/session/session-persistence.ts +30 -4
  131. package/src/session/settings-stream-fn.ts +14 -0
  132. package/src/slash-commands/builtin-registry.ts +35 -3
  133. package/src/system-prompt.ts +15 -1
  134. package/src/task/executor.ts +31 -8
  135. package/src/task/index.ts +31 -9
  136. package/src/task/isolation-runner.ts +18 -2
  137. package/src/task/parallel.ts +7 -2
  138. package/src/task/render.test.ts +121 -0
  139. package/src/task/render.ts +48 -2
  140. package/src/task/worktree.ts +12 -13
  141. package/src/task/yield-assembly.ts +8 -5
  142. package/src/thinking.ts +5 -0
  143. package/src/tools/ask.ts +188 -9
  144. package/src/tools/ast-edit.ts +7 -0
  145. package/src/tools/ast-grep.ts +11 -0
  146. package/src/tools/bash.ts +6 -30
  147. package/src/tools/checkpoint.ts +15 -1
  148. package/src/tools/eval-render.ts +15 -0
  149. package/src/tools/fetch.ts +82 -18
  150. package/src/tools/gh.ts +1 -1
  151. package/src/tools/grep.ts +45 -27
  152. package/src/tools/index.ts +3 -1
  153. package/src/tools/irc.ts +24 -9
  154. package/src/tools/output-meta.ts +50 -0
  155. package/src/tools/output-schema-validator.ts +152 -15
  156. package/src/tools/path-utils.ts +55 -10
  157. package/src/tools/read.ts +1 -1
  158. package/src/tools/render-utils.ts +5 -3
  159. package/src/tools/yield.ts +41 -1
  160. package/src/utils/commit-message-generator.ts +2 -2
  161. package/src/utils/git.ts +271 -29
  162. package/src/utils/thinking-display.ts +13 -0
  163. package/src/web/search/index.ts +9 -28
  164. package/src/web/search/provider.ts +26 -1
  165. package/src/web/search/providers/duckduckgo.ts +1 -1
  166. package/src/web/search/types.ts +5 -1
package/src/utils/git.ts CHANGED
@@ -2,6 +2,7 @@ import * as fs from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { $which, hasFsCode, isEisdir, isEnoent, isEnotdir, Snowflake } from "@oh-my-pi/pi-utils";
5
+ import type { Subprocess } from "bun";
5
6
  import {
6
7
  parseDiffHunks as parseCommitDiffHunks,
7
8
  parseFileDiffs,
@@ -103,6 +104,7 @@ export interface PatchOptions {
103
104
  readonly cached?: boolean;
104
105
  readonly check?: boolean;
105
106
  readonly env?: Record<string, string | undefined>;
107
+ readonly reverse?: boolean;
106
108
  readonly threeWay?: boolean;
107
109
  readonly signal?: AbortSignal;
108
110
  }
@@ -115,10 +117,18 @@ export interface RestoreOptions {
115
117
  readonly worktree?: boolean;
116
118
  }
117
119
 
120
+ export interface FetchOptions {
121
+ readonly signal?: AbortSignal;
122
+ /** Deadline for the network transfer. Defaults to {@link GIT_NETWORK_TIMEOUT_MS}. */
123
+ readonly timeoutMs?: number;
124
+ }
125
+
118
126
  export interface CloneOptions {
119
127
  readonly ref?: string;
120
128
  readonly sha?: string;
121
129
  readonly signal?: AbortSignal;
130
+ /** Deadline for the network transfer. Defaults to {@link GIT_NETWORK_TIMEOUT_MS}. */
131
+ readonly timeoutMs?: number;
122
132
  }
123
133
 
124
134
  interface GitHeadBase extends GitRepository {
@@ -174,7 +184,6 @@ const SHORT_LIVED_GIT_CONFIG: readonly (readonly [key: string, value: string])[]
174
184
  ["core.fsmonitor", "false"],
175
185
  ["core.untrackedCache", "false"],
176
186
  ];
177
- const REMOTE_ALREADY_EXISTS = /remote .* already exists/i;
178
187
  const AMBIENT_GIT_ENV = {
179
188
  GIT_DIR: undefined,
180
189
  GIT_COMMON_DIR: undefined,
@@ -184,11 +193,177 @@ const AMBIENT_GIT_ENV = {
184
193
  GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined,
185
194
  } satisfies Record<string, undefined>;
186
195
 
196
+ const GIT_NON_INTERACTIVE_ENV = {
197
+ GIT_ASKPASS: "true",
198
+ GIT_EDITOR: "true",
199
+ GIT_TERMINAL_PROMPT: "0",
200
+ GPG_TTY: "not a tty",
201
+ SSH_ASKPASS: "/usr/bin/false",
202
+ } satisfies Record<string, string>;
203
+ const GH_NON_INTERACTIVE_ENV = {
204
+ ...GIT_NON_INTERACTIVE_ENV,
205
+ GH_PROMPT_DISABLED: "1",
206
+ } satisfies Record<string, string>;
207
+
208
+ /** Default deadline for git and gh subprocesses spawned by the coding agent. */
209
+ export const GIT_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
210
+ /**
211
+ * Default deadline for git subprocesses that perform network transfers
212
+ * (`clone`/`fetch`). Large-repo transfers legitimately outlive
213
+ * {@link GIT_COMMAND_TIMEOUT_MS}, so they get a wider deadline; local plumbing
214
+ * commands keep the short one.
215
+ */
216
+ export const GIT_NETWORK_TIMEOUT_MS = 30 * 60 * 1000;
217
+ /** Maximum captured stdout or stderr bytes retained from git and gh subprocesses. */
218
+ export const GIT_COMMAND_OUTPUT_LIMIT_BYTES = 8 * 1024 * 1024;
219
+
220
+ const GIT_COMMAND_TIMEOUT_EXIT_CODE = 124;
221
+ const GIT_OUTPUT_TRUNCATED_MARKER = "\n[git subprocess output truncated after 8 MiB]\n";
222
+ const GIT_COMMAND_TERMINATE_GRACE_MS = 5_000;
223
+
224
+ type CommandName = "git" | "gh";
225
+
226
+ function resolveTimeoutMs(timeoutMs: number | undefined, fallback: number = GIT_COMMAND_TIMEOUT_MS): number {
227
+ if (timeoutMs === undefined) return fallback;
228
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) return fallback;
229
+ return Math.trunc(timeoutMs);
230
+ }
231
+
232
+ function resolveOutputLimit(maxOutputBytes: number | undefined): number {
233
+ if (maxOutputBytes === undefined) return GIT_COMMAND_OUTPUT_LIMIT_BYTES;
234
+ if (!Number.isFinite(maxOutputBytes) || maxOutputBytes < 0) return GIT_COMMAND_OUTPUT_LIMIT_BYTES;
235
+ return Math.trunc(maxOutputBytes);
236
+ }
237
+
238
+ function formatCommandLabel(command: CommandName, args: readonly string[]): string {
239
+ return `${command} ${args.join(" ")}`.trim();
240
+ }
241
+
242
+ async function waitForChildExit(child: Subprocess, timeoutMs: number): Promise<boolean> {
243
+ if (timeoutMs <= 0) return false;
244
+ const timeout = Promise.withResolvers<false>();
245
+ const timer = setTimeout(() => timeout.resolve(false), timeoutMs);
246
+ timer.unref?.();
247
+ try {
248
+ return await Promise.race([
249
+ child.exited.then(
250
+ () => true,
251
+ () => true,
252
+ ),
253
+ timeout.promise,
254
+ ]);
255
+ } finally {
256
+ clearTimeout(timer);
257
+ }
258
+ }
259
+
260
+ async function terminateTimedOutChild(child: Subprocess): Promise<void> {
261
+ child.kill("SIGTERM");
262
+ if (await waitForChildExit(child, GIT_COMMAND_TERMINATE_GRACE_MS)) return;
263
+ child.kill("SIGKILL");
264
+ await waitForChildExit(child, GIT_COMMAND_TERMINATE_GRACE_MS);
265
+ }
266
+
267
+ async function waitForExitWithTimeout(
268
+ child: Subprocess,
269
+ commandLabel: string,
270
+ timeoutMs: number,
271
+ ): Promise<{ exitCode: number | null; timedOut: false } | { timedOut: true; stderr: string }> {
272
+ if (timeoutMs === 0) {
273
+ await terminateTimedOutChild(child);
274
+ return { timedOut: true, stderr: `${commandLabel} timed out after 0ms` };
275
+ }
276
+ const timeout = Promise.withResolvers<"timeout">();
277
+ const timer = setTimeout(() => timeout.resolve("timeout"), timeoutMs);
278
+ timer.unref?.();
279
+ try {
280
+ const result = await Promise.race([
281
+ child.exited.then(exitCode => ({ kind: "exit" as const, exitCode })),
282
+ timeout.promise.then(() => ({ kind: "timeout" as const })),
283
+ ]);
284
+ if (result.kind === "exit") {
285
+ return { timedOut: false, exitCode: result.exitCode };
286
+ }
287
+ await terminateTimedOutChild(child);
288
+ return { timedOut: true, stderr: `${commandLabel} timed out after ${timeoutMs}ms` };
289
+ } finally {
290
+ clearTimeout(timer);
291
+ }
292
+ }
293
+
294
+ async function readCappedText(stream: ReadableStream<Uint8Array>, maxBytes: number): Promise<string> {
295
+ const reader = stream.getReader();
296
+ const decoder = new TextDecoder();
297
+ const chunks: string[] = [];
298
+ let remaining = maxBytes;
299
+ let truncated = false;
300
+ try {
301
+ while (true) {
302
+ const { done, value } = await reader.read();
303
+ if (done) break;
304
+ if (!truncated && value.length <= remaining) {
305
+ chunks.push(decoder.decode(value, { stream: true }));
306
+ remaining -= value.length;
307
+ continue;
308
+ }
309
+ if (!truncated && remaining > 0) {
310
+ chunks.push(decoder.decode(value.subarray(0, remaining), { stream: true }));
311
+ remaining = 0;
312
+ }
313
+ truncated = true;
314
+ }
315
+ chunks.push(decoder.decode());
316
+ if (truncated) chunks.push(GIT_OUTPUT_TRUNCATED_MARKER);
317
+ return chunks.join("");
318
+ } finally {
319
+ reader.releaseLock();
320
+ }
321
+ }
322
+
323
+ async function cancelOutput(stream: ReadableStream<Uint8Array>): Promise<void> {
324
+ try {
325
+ await stream.cancel();
326
+ } catch {
327
+ // Best-effort cleanup after a timeout; the subprocess has already been signaled.
328
+ }
329
+ }
330
+
331
+ async function collectSubprocessResult(
332
+ command: CommandName,
333
+ args: readonly string[],
334
+ child: Subprocess,
335
+ options: Pick<CommandOptions, "maxOutputBytes" | "timeoutMs"> = {},
336
+ ): Promise<GitCommandResult> {
337
+ const stdoutStream = child.stdout;
338
+ const stderrStream = child.stderr;
339
+ if (!(stdoutStream instanceof ReadableStream) || !(stderrStream instanceof ReadableStream)) {
340
+ throw new Error(`Failed to capture ${command} command output.`);
341
+ }
342
+ const maxOutputBytes = resolveOutputLimit(options.maxOutputBytes);
343
+ const stdoutPromise = readCappedText(stdoutStream, maxOutputBytes);
344
+ const stderrPromise = readCappedText(stderrStream, maxOutputBytes);
345
+ const exit = await waitForExitWithTimeout(
346
+ child,
347
+ formatCommandLabel(command, args),
348
+ resolveTimeoutMs(options.timeoutMs),
349
+ );
350
+ if (exit.timedOut) {
351
+ void stdoutPromise.catch(() => undefined);
352
+ void stderrPromise.catch(() => undefined);
353
+ await Promise.all([cancelOutput(stdoutStream), cancelOutput(stderrStream)]);
354
+ return { exitCode: GIT_COMMAND_TIMEOUT_EXIT_CODE, stdout: "", stderr: exit.stderr };
355
+ }
356
+ const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
357
+ return { exitCode: exit.exitCode ?? 0, stdout, stderr };
358
+ }
359
+
187
360
  interface CommandOptions {
188
361
  readonly env?: Record<string, string | undefined>;
362
+ readonly maxOutputBytes?: number;
189
363
  readonly readOnly?: boolean;
190
364
  readonly signal?: AbortSignal;
191
365
  readonly stdin?: string | Uint8Array | ArrayBuffer | SharedArrayBuffer;
366
+ readonly timeoutMs?: number;
192
367
  }
193
368
 
194
369
  function normalizeStdin(input: CommandOptions["stdin"]): "ignore" | Uint8Array {
@@ -204,6 +379,7 @@ function buildGitEnv(overrides?: Record<string, string | undefined>): Record<str
204
379
  GIT_OPTIONAL_LOCKS: "0",
205
380
  ...AMBIENT_GIT_ENV,
206
381
  ...overrides,
382
+ ...GIT_NON_INTERACTIVE_ENV,
207
383
  };
208
384
  }
209
385
 
@@ -236,17 +412,7 @@ async function git(cwd: string, args: readonly string[], options: CommandOptions
236
412
  windowsHide: true,
237
413
  });
238
414
 
239
- if (!child.stdout || !child.stderr) {
240
- throw new Error("Failed to capture git command output.");
241
- }
242
-
243
- const [stdout, stderr, exitCode] = await Promise.all([
244
- new Response(child.stdout).text(),
245
- new Response(child.stderr).text(),
246
- child.exited,
247
- ]);
248
-
249
- return { exitCode: exitCode ?? 0, stdout, stderr };
415
+ return await collectSubprocessResult("git", commandArgs, child, options);
250
416
  }
251
417
 
252
418
  function withNoOptionalLocks(args: readonly string[]): string[] {
@@ -389,6 +555,7 @@ function buildApplyArgs(patchPath: string, options: PatchOptions): string[] {
389
555
  const args = ["apply"];
390
556
  if (options.check) args.push("--check");
391
557
  if (options.cached) args.push("--cached");
558
+ if (options.reverse) args.push("--reverse");
392
559
  if (options.threeWay) args.push("--3way");
393
560
  args.push("--binary", patchPath);
394
561
  return args;
@@ -1185,15 +1352,18 @@ export async function checkout(cwd: string, ref: string, signal?: AbortSignal):
1185
1352
  await runEffect(cwd, ["checkout", ref], { signal });
1186
1353
  }
1187
1354
 
1188
- /** Fetch a specific refspec from a remote. */
1355
+ /** Fetch a specific refspec from a remote. Network transfer: defaults to the {@link GIT_NETWORK_TIMEOUT_MS} deadline. */
1189
1356
  export async function fetch(
1190
1357
  cwd: string,
1191
1358
  remote: string,
1192
1359
  source: string,
1193
1360
  target: string,
1194
- signal?: AbortSignal,
1361
+ options: FetchOptions = {},
1195
1362
  ): Promise<void> {
1196
- await runEffect(cwd, ["fetch", remote, `+${source}:${target}`], { signal });
1363
+ await runEffect(cwd, ["fetch", remote, `+${source}:${target}`], {
1364
+ signal: options.signal,
1365
+ timeoutMs: resolveTimeoutMs(options.timeoutMs, GIT_NETWORK_TIMEOUT_MS),
1366
+ });
1197
1367
  }
1198
1368
 
1199
1369
  /** Read a tree-ish into the index. */
@@ -1370,10 +1540,10 @@ export const remote = {
1370
1540
  async add(cwd: string, name: string, url: string, signal?: AbortSignal): Promise<void> {
1371
1541
  const result = await git(cwd, ["remote", "add", name, url], { signal });
1372
1542
  if (result.exitCode === 0) return;
1373
- if (REMOTE_ALREADY_EXISTS.test(result.stderr)) {
1374
- const existing = await remote.url(cwd, name, signal);
1543
+ const existing = await remote.url(cwd, name, signal);
1544
+ if (existing !== undefined) {
1375
1545
  if (existing === url) return;
1376
- throw new ToolError(`remote ${name} already exists with URL ${existing ?? "(unset)"}, expected ${url}`);
1546
+ throw new ToolError(`remote ${name} already exists with URL ${existing}, expected ${url}`);
1377
1547
  }
1378
1548
  throw new GitCommandError(["remote", "add", name, url], result);
1379
1549
  },
@@ -1580,6 +1750,69 @@ export const stash = {
1580
1750
  if (options?.index) args.push("--index");
1581
1751
  await runEffect(cwd, args);
1582
1752
  },
1753
+ /**
1754
+ * Return the working-tree patch that `stash@{0}` would apply, in a form
1755
+ * that `git apply --check` can consume. Empty string when no stash entry
1756
+ * exists or the stash contains no diffable working-tree changes.
1757
+ */
1758
+ async showPatch(cwd: string): Promise<string> {
1759
+ return (await tryText(cwd, ["stash", "show", "-p", "--binary", "stash@{0}"], { readOnly: true })) ?? "";
1760
+ },
1761
+ /** Return untracked paths stored in the top stash entry. */
1762
+ async untrackedFiles(cwd: string): Promise<string[]> {
1763
+ const output = await tryText(cwd, ["ls-tree", "-r", "-z", "--name-only", "stash@{0}^3"], { readOnly: true });
1764
+ return output?.split("\0").filter(Boolean) ?? [];
1765
+ },
1766
+ /**
1767
+ * Attempt to restore the top stash entry. On success returns `true` and
1768
+ * git drops the stash entry. On conflict returns `false`, leaves the stash
1769
+ * entry preserved for manual resolution, and guarantees the failed restore
1770
+ * leaves no unmerged index entries or partially-restored untracked files.
1771
+ *
1772
+ * The historical raw `pop` catches the failure in a `finally` block and
1773
+ * only logs — it leaves `.git/index` with stage 1/2/3 unmerged entries
1774
+ * that survive indefinitely, corrupting every subsequent overlay-isolated
1775
+ * task that reads through this repo's `.git/`. See issue #4175.
1776
+ */
1777
+ async tryPop(cwd: string, options?: { index?: boolean }): Promise<boolean> {
1778
+ // Preflight: `git stash pop` internally does a 3-way merge, so a plain
1779
+ // `git apply --check` is too strict — it rejects hunks whose context
1780
+ // drifted from HEAD even when 3-way merge would resolve them cleanly.
1781
+ // Match pop's semantics with `--3way --check`, which succeeds iff the
1782
+ // patch either applies directly or merges without conflict against
1783
+ // the patch's `index abc..def` base blobs.
1784
+ const workingPatch = await stash.showPatch(cwd);
1785
+ if (workingPatch.trim() && !(await patch.canApplyText(cwd, workingPatch, { threeWay: true }))) {
1786
+ return false;
1787
+ }
1788
+ const restoredUntracked = await stash.untrackedFiles(cwd);
1789
+ try {
1790
+ await stash.pop(cwd, options);
1791
+ return true;
1792
+ } catch {
1793
+ // Preflight can still miss mode-only or delete/modify conflicts. If
1794
+ // the pop left unmerged entries, wipe them: HEAD holds the merged
1795
+ // state so `reset --hard HEAD` restores a clean index and working
1796
+ // tree without losing the cherry-picked commits. A failed pop can
1797
+ // still restore unrelated untracked files before exiting while
1798
+ // preserving the stash entry, so clean only the untracked paths
1799
+ // recorded in that stash. The user's WIP remains recoverable via
1800
+ // `git stash pop`.
1801
+ try {
1802
+ await reset(cwd, { hard: true });
1803
+ } catch {
1804
+ /* best-effort cleanup — do not mask the primary conflict */
1805
+ }
1806
+ if (restoredUntracked.length > 0) {
1807
+ try {
1808
+ await clean(cwd, { includeIgnored: true, literalPathspecs: true, paths: restoredUntracked });
1809
+ } catch {
1810
+ /* best-effort cleanup — do not mask the primary conflict */
1811
+ }
1812
+ }
1813
+ return false;
1814
+ }
1815
+ },
1583
1816
  };
1584
1817
 
1585
1818
  // ════════════════════════════════════════════════════════════════════════════
@@ -1603,7 +1836,10 @@ export async function clone(url: string, targetDir: string, options: CloneOption
1603
1836
  args.push(url, absoluteTarget);
1604
1837
 
1605
1838
  try {
1606
- await runEffect(path.dirname(absoluteTarget), args, { signal: options.signal });
1839
+ await runEffect(path.dirname(absoluteTarget), args, {
1840
+ signal: options.signal,
1841
+ timeoutMs: resolveTimeoutMs(options.timeoutMs, GIT_NETWORK_TIMEOUT_MS),
1842
+ });
1607
1843
  if (options.sha) {
1608
1844
  try {
1609
1845
  await checkout(absoluteTarget, options.sha, options.signal);
@@ -1646,9 +1882,18 @@ export async function reset(
1646
1882
 
1647
1883
  export async function clean(
1648
1884
  cwd: string,
1649
- options: { ignoredOnly?: boolean; paths?: readonly string[]; signal?: AbortSignal } = {},
1885
+ options: {
1886
+ ignoredOnly?: boolean;
1887
+ includeIgnored?: boolean;
1888
+ literalPathspecs?: boolean;
1889
+ paths?: readonly string[];
1890
+ signal?: AbortSignal;
1891
+ } = {},
1650
1892
  ): Promise<void> {
1651
- const args = ["clean", options.ignoredOnly ? "-fdX" : "-fd"];
1893
+ const args = [options.literalPathspecs ? "--literal-pathspecs" : undefined, "clean"].filter(
1894
+ (arg): arg is string => arg !== undefined,
1895
+ );
1896
+ args.push(options.ignoredOnly ? "-fdX" : options.includeIgnored ? "-fdx" : "-fd");
1652
1897
  if (options.paths?.length) args.push("--", ...options.paths);
1653
1898
  await runEffect(cwd, args, { signal: options.signal });
1654
1899
  }
@@ -1857,20 +2102,17 @@ export const github = {
1857
2102
  try {
1858
2103
  const child = Bun.spawn(["gh", ...args], {
1859
2104
  cwd,
2105
+ env: {
2106
+ ...process.env,
2107
+ ...GH_NON_INTERACTIVE_ENV,
2108
+ },
1860
2109
  stdin: "ignore",
1861
2110
  stdout: "pipe",
1862
2111
  stderr: "pipe",
1863
2112
  windowsHide: true,
1864
2113
  signal,
1865
2114
  });
1866
- if (!child.stdout || !child.stderr) {
1867
- throw new ToolError("Failed to capture GitHub CLI output.");
1868
- }
1869
- const [stdout, stderr, exitCode] = await Promise.all([
1870
- new Response(child.stdout).text(),
1871
- new Response(child.stderr).text(),
1872
- child.exited,
1873
- ]);
2115
+ const { stdout, stderr, exitCode } = await collectSubprocessResult("gh", args, child, {});
1874
2116
  throwIfAborted(signal);
1875
2117
  const trim = options?.trimOutput !== false;
1876
2118
  return {
@@ -1,5 +1,15 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
 
3
+ // Single-entry memo for the proseOnly formatting path. During a streaming tick
4
+ // the same growing thinking text is formatted up to three times (reveal count,
5
+ // reveal slice, component render); this collapses them to one computation. The
6
+ // `proseOnly === false` branch is a passthrough and never consults the cache, so
7
+ // the key can be the text alone. A single entry is enough for the common case of
8
+ // one active thinking block and never regresses (a miss recomputes exactly as
9
+ // before).
10
+ let formatCacheKey = "";
11
+ let formatCacheValue = "";
12
+
3
13
  export function canonicalizeMessage(text: string | null | undefined): string {
4
14
  if (!text) return "";
5
15
  const trimmed = text.trim();
@@ -14,6 +24,7 @@ export function canonicalizeMessage(text: string | null | undefined): string {
14
24
 
15
25
  export function formatThinkingForDisplay(text: string, proseOnly: boolean): string {
16
26
  if (!proseOnly || !text) return text;
27
+ if (text === formatCacheKey) return formatCacheValue;
17
28
 
18
29
  const lines = text.split("\n");
19
30
  const resultLines: string[] = [];
@@ -78,6 +89,8 @@ export function formatThinkingForDisplay(text: string, proseOnly: boolean): stri
78
89
  }
79
90
 
80
91
  const formatted = resultLines.join("\n");
92
+ formatCacheKey = text;
93
+ formatCacheValue = formatted;
81
94
  return formatted;
82
95
  }
83
96
 
@@ -17,7 +17,13 @@ import { discoverAuthStorage } from "../../sdk";
17
17
  import type { ToolSession } from "../../tools";
18
18
  import { formatAge } from "../../tools/render-utils";
19
19
  import { throwIfAborted } from "../../tools/tool-errors";
20
- import { getSearchProvider, getSearchProviderLabel, resolveProviderChain, type SearchProvider } from "./provider";
20
+ import {
21
+ formatSearchProviderFailure,
22
+ formatSearchProviderFailures,
23
+ getSearchProvider,
24
+ resolveProviderChain,
25
+ type SearchProvider,
26
+ } from "./provider";
21
27
  import { renderSearchCall, renderSearchResult, type SearchRenderDetails } from "./render";
22
28
  import type { SearchProviderId, SearchResponse } from "./types";
23
29
  import { SearchProviderError } from "./types";
@@ -38,23 +44,6 @@ export interface SearchQueryParams extends SearchToolParams {
38
44
  provider?: SearchProviderId | "auto";
39
45
  }
40
46
 
41
- function formatProviderError(error: unknown, provider: SearchProvider): string {
42
- if (error instanceof SearchProviderError) {
43
- if (error.provider === "anthropic" && error.status === 404) {
44
- return "Anthropic web search returned 404 (model or endpoint not found).";
45
- }
46
- if (error.status === 401 || error.status === 403) {
47
- if (error.provider === "zai") {
48
- return error.message;
49
- }
50
- return `${getSearchProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
51
- }
52
- return error.message;
53
- }
54
- if (error instanceof Error) return error.message;
55
- return `Unknown error from ${provider.label}`;
56
- }
57
-
58
47
  /** Truncate text for tool output */
59
48
  function truncateText(text: string, maxLen: number): string {
60
49
  if (text.length <= maxLen) return text;
@@ -212,18 +201,10 @@ async function executeSearch(
212
201
 
213
202
  const lastFailure = failures[failures.length - 1];
214
203
  const baseMessage = lastFailure
215
- ? formatProviderError(lastFailure.error, lastFailure.provider)
204
+ ? formatSearchProviderFailure(lastFailure.error, lastFailure.provider)
216
205
  : `Unknown error from ${lastProvider.label}`;
217
206
  const message =
218
- providers.length > 1
219
- ? `All web search providers failed: ${failures
220
- .map(f =>
221
- f.error instanceof SearchProviderError
222
- ? f.error.message
223
- : `${f.provider.id}: ${formatProviderError(f.error, f.provider)}`,
224
- )
225
- .join("; ")}`
226
- : baseMessage;
207
+ providers.length > 1 ? `All web search providers failed: ${formatSearchProviderFailures(failures)}` : baseMessage;
227
208
 
228
209
  return {
229
210
  content: [{ type: "text" as const, text: `Error: ${message}` }],
@@ -10,7 +10,7 @@
10
10
 
11
11
  import type { AuthStorage } from "@oh-my-pi/pi-ai";
12
12
  import type { SearchProvider } from "./providers/base";
13
- import { SEARCH_PROVIDER_LABELS, SEARCH_PROVIDER_ORDER, type SearchProviderId } from "./types";
13
+ import { SEARCH_PROVIDER_LABELS, SEARCH_PROVIDER_ORDER, SearchProviderError, type SearchProviderId } from "./types";
14
14
 
15
15
  export type { SearchParams } from "./providers/base";
16
16
  export { SearchProvider } from "./providers/base";
@@ -123,6 +123,31 @@ export function getSearchProviderLabel(id: SearchProviderId): string {
123
123
  return PROVIDER_META[id]?.label ?? id;
124
124
  }
125
125
 
126
+ /** Format one provider failure for the user-facing fallback summary. */
127
+ export function formatSearchProviderFailure(error: unknown, provider: Pick<SearchProvider, "id" | "label">): string {
128
+ if (error instanceof SearchProviderError) {
129
+ if (error.provider === "anthropic" && error.status === 404) {
130
+ return "Anthropic web search returned 404 (model or endpoint not found).";
131
+ }
132
+ if (error.status === 401 || error.status === 403) {
133
+ if (error.provider === "zai") {
134
+ return error.message;
135
+ }
136
+ return `${getSearchProviderLabel(error.provider)} authorization failed (${error.status}). Check API key or base URL.`;
137
+ }
138
+ return error.message;
139
+ }
140
+ if (error instanceof Error) return error.message;
141
+ return `Unknown error from ${provider.label}`;
142
+ }
143
+
144
+ /** Format the ordered provider fallback failures for terminal/tool output. */
145
+ export function formatSearchProviderFailures(
146
+ failures: readonly { provider: Pick<SearchProvider, "id" | "label">; error: unknown }[],
147
+ ): string {
148
+ return failures.map(f => `${f.provider.id}: ${formatSearchProviderFailure(f.error, f.provider)}`).join("; ");
149
+ }
150
+
126
151
  /**
127
152
  * Resolve and cache a provider instance. First call for a given id loads the
128
153
  * underlying module; subsequent calls return the cached singleton.
@@ -167,7 +167,7 @@ async function callDuckDuckGoHtml(params: SearchParams): Promise<string> {
167
167
  if (isAnomalyResponse(body)) {
168
168
  throw new SearchProviderError(
169
169
  "duckduckgo",
170
- "DuckDuckGo blocked the request with a bot-detection challenge. DuckDuckGo throttles repeat searches from datacenter and shared-egress IPs; configure another provider (e.g. Brave, Tavily, SearXNG) or retry from a residential network.",
170
+ "DuckDuckGo blocked the request with a bot-detection challenge. DuckDuckGo throttles automated HTML searches from datacenter/shared-egress IPs; configure a credentialed provider such as Brave, Tavily, Exa, or Kagi for reliable web search.",
171
171
  429,
172
172
  );
173
173
  }
@@ -43,7 +43,11 @@ export const SEARCH_PROVIDER_OPTIONS = [
43
43
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
44
44
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
45
45
  { value: "searxng", label: "SearXNG", description: "Requires SEARXNG_ENDPOINT or searxng.endpoint" },
46
- { value: "duckduckgo", label: "DuckDuckGo", description: "Scrapes the DuckDuckGo HTML frontend (no API key)" },
46
+ {
47
+ value: "duckduckgo",
48
+ label: "DuckDuckGo",
49
+ description: "Credential-free best-effort fallback; may be bot-challenged on datacenter/shared-egress IPs",
50
+ },
47
51
  ] as const;
48
52
 
49
53
  /** Supported web search providers (every option except `auto`). */