@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.
- package/CHANGELOG.md +108 -7
- package/dist/cli.js +6033 -5982
- package/dist/types/advisor/config.d.ts +4 -2
- package/dist/types/collab/host.d.ts +16 -0
- package/dist/types/collab/protocol.d.ts +9 -3
- package/dist/types/commit/model-selection.d.ts +5 -0
- package/dist/types/config/model-resolver.d.ts +12 -10
- package/dist/types/config/settings-schema.d.ts +28 -5
- package/dist/types/edit/modes/patch.d.ts +11 -0
- package/dist/types/eval/agent-bridge.d.ts +5 -1
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +86 -2
- package/dist/types/extensibility/skills.d.ts +2 -1
- package/dist/types/mnemopi/state.d.ts +12 -0
- package/dist/types/modes/components/agent-hub.d.ts +9 -5
- package/dist/types/modes/components/status-line/component.test.d.ts +1 -0
- package/dist/types/modes/components/usage-row.d.ts +1 -1
- package/dist/types/modes/controllers/command-controller.d.ts +0 -1
- package/dist/types/modes/controllers/extension-ui-controller.d.ts +6 -0
- package/dist/types/modes/controllers/streaming-reveal.d.ts +17 -1
- package/dist/types/modes/controllers/tool-args-reveal.d.ts +30 -3
- package/dist/types/modes/interactive-mode.d.ts +12 -7
- package/dist/types/modes/rpc/rpc-client.d.ts +5 -0
- package/dist/types/modes/rpc/rpc-mode.d.ts +64 -1
- package/dist/types/modes/session-teardown.d.ts +63 -0
- package/dist/types/modes/session-teardown.test.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +9 -4
- package/dist/types/modes/types.d.ts +2 -3
- package/dist/types/modes/utils/copy-targets.d.ts +2 -0
- package/dist/types/plan-mode/approved-plan-prompt.test.d.ts +1 -0
- package/dist/types/sdk.d.ts +9 -0
- package/dist/types/session/agent-session.d.ts +46 -13
- package/dist/types/session/exit-diagnostics.d.ts +48 -0
- package/dist/types/session/session-loader.d.ts +5 -0
- package/dist/types/task/executor.d.ts +12 -5
- package/dist/types/task/parallel.d.ts +1 -0
- package/dist/types/task/render.test.d.ts +1 -0
- package/dist/types/thinking.d.ts +2 -0
- package/dist/types/tools/checkpoint.d.ts +8 -0
- package/dist/types/tools/eval-render.d.ts +1 -0
- package/dist/types/tools/fetch.d.ts +11 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/tools/irc.d.ts +1 -0
- package/dist/types/tools/output-meta.d.ts +7 -0
- package/dist/types/tools/output-schema-validator.d.ts +7 -4
- package/dist/types/tools/path-utils.d.ts +16 -0
- package/dist/types/tools/render-utils.d.ts +4 -1
- package/dist/types/utils/git.d.ts +47 -2
- package/dist/types/web/search/provider.d.ts +7 -0
- package/dist/types/web/search/types.d.ts +1 -1
- package/package.json +12 -12
- package/src/advisor/config.ts +4 -2
- package/src/cli/bench-cli.ts +7 -2
- package/src/collab/guest.ts +94 -5
- package/src/collab/host.ts +80 -3
- package/src/collab/protocol.ts +9 -2
- package/src/commit/model-selection.ts +8 -2
- package/src/config/keybindings.ts +3 -1
- package/src/config/model-discovery.ts +66 -2
- package/src/config/model-registry.ts +7 -3
- package/src/config/model-resolver.ts +51 -23
- package/src/config/settings-schema.ts +44 -14
- package/src/edit/hashline/diff.ts +13 -2
- package/src/edit/index.ts +58 -8
- package/src/edit/modes/patch.ts +53 -18
- package/src/edit/streaming.ts +7 -6
- package/src/eval/__tests__/agent-bridge.test.ts +57 -1
- package/src/eval/agent-bridge.ts +15 -5
- package/src/exec/bash-executor.ts +7 -12
- package/src/extensibility/legacy-pi-coding-agent-shim.ts +534 -16
- package/src/extensibility/skills.ts +29 -6
- package/src/goals/guided-setup.ts +4 -3
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/lsp/client.ts +11 -14
- package/src/main.ts +38 -10
- package/src/mnemopi/state.ts +43 -7
- package/src/modes/acp/acp-agent.ts +1 -1
- package/src/modes/components/agent-hub.ts +10 -2
- package/src/modes/components/agent-transcript-viewer.ts +39 -7
- package/src/modes/components/assistant-message.ts +16 -10
- package/src/modes/components/chat-transcript-builder.ts +11 -1
- package/src/modes/components/custom-editor.test.ts +20 -0
- package/src/modes/components/hook-selector.ts +44 -25
- package/src/modes/components/model-selector.ts +1 -1
- package/src/modes/components/status-line/component.test.ts +44 -0
- package/src/modes/components/status-line/component.ts +9 -1
- package/src/modes/components/status-line/segments.ts +3 -3
- package/src/modes/components/usage-row.ts +16 -2
- package/src/modes/controllers/command-controller.ts +6 -21
- package/src/modes/controllers/event-controller.ts +11 -9
- package/src/modes/controllers/extension-ui-controller.ts +84 -3
- package/src/modes/controllers/input-controller.ts +16 -13
- package/src/modes/controllers/selector-controller.ts +3 -8
- package/src/modes/controllers/streaming-reveal.ts +75 -53
- package/src/modes/controllers/tool-args-reveal.ts +340 -10
- package/src/modes/interactive-mode.ts +122 -79
- package/src/modes/rpc/rpc-client.ts +21 -0
- package/src/modes/rpc/rpc-mode.ts +197 -46
- package/src/modes/session-teardown.test.ts +219 -0
- package/src/modes/session-teardown.ts +82 -0
- package/src/modes/setup-wizard/scenes/theme.ts +2 -2
- package/src/modes/skill-command.ts +7 -20
- package/src/modes/theme/theme.ts +29 -15
- package/src/modes/types.ts +2 -3
- package/src/modes/utils/copy-targets.ts +12 -0
- package/src/modes/utils/ui-helpers.ts +19 -2
- package/src/plan-mode/approved-plan-prompt.test.ts +36 -0
- package/src/prompts/advisor/system.md +1 -1
- package/src/prompts/agents/tester.md +6 -2
- package/src/prompts/skills/autoload.md +8 -0
- package/src/prompts/skills/user-invocation.md +11 -0
- package/src/prompts/steering/parent-irc.md +5 -0
- package/src/prompts/system/irc-incoming.md +2 -0
- package/src/prompts/system/mid-run-todo-nudge.md +3 -0
- package/src/prompts/system/plan-mode-approved.md +7 -10
- package/src/prompts/system/plan-mode-compact-instructions.md +2 -1
- package/src/prompts/system/plan-mode-reference.md +3 -4
- package/src/prompts/system/rewind-report.md +6 -0
- package/src/prompts/system/subagent-system-prompt.md +3 -0
- package/src/prompts/tools/irc.md +2 -1
- package/src/prompts/tools/job.md +2 -2
- package/src/prompts/tools/rewind.md +3 -2
- package/src/prompts/tools/task.md +4 -0
- package/src/sdk.ts +18 -4
- package/src/session/agent-session.ts +660 -114
- package/src/session/exit-diagnostics.ts +202 -0
- package/src/session/session-context.ts +25 -13
- package/src/session/session-loader.ts +58 -25
- package/src/session/session-manager.ts +0 -1
- package/src/session/session-persistence.ts +30 -4
- package/src/session/settings-stream-fn.ts +14 -0
- package/src/slash-commands/builtin-registry.ts +35 -3
- package/src/system-prompt.ts +15 -1
- package/src/task/executor.ts +31 -8
- package/src/task/index.ts +31 -9
- package/src/task/isolation-runner.ts +18 -2
- package/src/task/parallel.ts +7 -2
- package/src/task/render.test.ts +121 -0
- package/src/task/render.ts +48 -2
- package/src/task/worktree.ts +12 -13
- package/src/task/yield-assembly.ts +8 -5
- package/src/thinking.ts +5 -0
- package/src/tools/ask.ts +188 -9
- package/src/tools/ast-edit.ts +7 -0
- package/src/tools/ast-grep.ts +11 -0
- package/src/tools/bash.ts +6 -30
- package/src/tools/checkpoint.ts +15 -1
- package/src/tools/eval-render.ts +15 -0
- package/src/tools/fetch.ts +82 -18
- package/src/tools/gh.ts +1 -1
- package/src/tools/grep.ts +45 -27
- package/src/tools/index.ts +3 -1
- package/src/tools/irc.ts +24 -9
- package/src/tools/output-meta.ts +50 -0
- package/src/tools/output-schema-validator.ts +152 -15
- package/src/tools/path-utils.ts +55 -10
- package/src/tools/read.ts +1 -1
- package/src/tools/render-utils.ts +5 -3
- package/src/tools/yield.ts +41 -1
- package/src/utils/commit-message-generator.ts +2 -2
- package/src/utils/git.ts +271 -29
- package/src/utils/thinking-display.ts +13 -0
- package/src/web/search/index.ts +9 -28
- package/src/web/search/provider.ts +26 -1
- package/src/web/search/providers/duckduckgo.ts +1 -1
- 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
|
-
|
|
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
|
-
|
|
1361
|
+
options: FetchOptions = {},
|
|
1195
1362
|
): Promise<void> {
|
|
1196
|
-
await runEffect(cwd, ["fetch", remote, `+${source}:${target}`], {
|
|
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
|
-
|
|
1374
|
-
|
|
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
|
|
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, {
|
|
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: {
|
|
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 = [
|
|
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
|
-
|
|
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
|
|
package/src/web/search/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
?
|
|
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
|
|
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
|
}
|
package/src/web/search/types.ts
CHANGED
|
@@ -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
|
-
{
|
|
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`). */
|