@oh-my-pi/pi-coding-agent 16.3.2 → 16.3.4
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 +44 -0
- package/dist/cli.js +3553 -3540
- package/dist/types/cli/update-cli.d.ts +1 -0
- package/dist/types/cli/update-cli.test.d.ts +1 -0
- package/dist/types/commands/update.d.ts +5 -0
- package/dist/types/config/config-file.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +10 -0
- package/dist/types/dap/client.d.ts +9 -1
- package/dist/types/edit/file-snapshot-store.d.ts +9 -1
- package/dist/types/edit/hashline/execute.d.ts +1 -1
- package/dist/types/edit/hashline/params.d.ts +3 -6
- package/dist/types/edit/renderer.d.ts +1 -0
- package/dist/types/extensibility/plugins/parser.d.ts +2 -1
- package/dist/types/hindsight/client.d.ts +15 -11
- package/dist/types/hindsight/client.test.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.d.ts +1 -0
- package/dist/types/mcp/smithery-registry.test.d.ts +1 -0
- package/dist/types/modes/components/advisor-message.d.ts +4 -2
- package/dist/types/modes/components/tool-execution.d.ts +9 -6
- package/dist/types/modes/components/transcript-container.d.ts +1 -0
- package/dist/types/modes/theme/theme.d.ts +1 -1
- package/dist/types/session/indexed-session-storage.d.ts +2 -2
- package/dist/types/session/session-storage.d.ts +31 -3
- package/dist/types/ssh/__tests__/connection-manager-timeout.test.d.ts +1 -0
- package/dist/types/ssh/__tests__/sshfs-mount.test.d.ts +1 -0
- package/dist/types/ssh/connection-manager.d.ts +19 -0
- package/dist/types/ssh/sshfs-mount.d.ts +10 -1
- package/dist/types/subprocess/worker-client.d.ts +20 -6
- package/dist/types/tools/bash.d.ts +27 -0
- package/dist/types/tools/renderers.d.ts +13 -0
- package/dist/types/tools/ssh.d.ts +2 -0
- package/dist/types/utils/fetch-timeout.d.ts +4 -0
- package/dist/types/web/search/providers/base.d.ts +1 -0
- package/dist/types/web/search/providers/gemini.d.ts +1 -0
- package/package.json +12 -12
- package/scripts/generate-legacy-pi-bundled-registry.ts +8 -2
- package/src/cli/models-cli.ts +19 -0
- package/src/cli/update-cli.test.ts +28 -0
- package/src/cli/update-cli.ts +35 -8
- package/src/cli/usage-cli.ts +34 -5
- package/src/commands/update.ts +8 -2
- package/src/config/config-file.ts +6 -6
- package/src/config/settings-schema.ts +10 -0
- package/src/dap/client.ts +134 -36
- package/src/edit/file-snapshot-store.ts +12 -1
- package/src/edit/hashline/diff.ts +4 -13
- package/src/edit/hashline/execute.ts +1 -1
- package/src/edit/hashline/params.ts +5 -12
- package/src/edit/renderer.ts +4 -2
- package/src/edit/streaming.ts +15 -5
- package/src/export/html/tool-views.generated.js +2 -2
- package/src/extensibility/plugins/installer.ts +12 -3
- package/src/extensibility/plugins/manager.ts +32 -8
- package/src/extensibility/plugins/parser.ts +7 -5
- package/src/extensibility/tool-event-input.ts +1 -1
- package/src/hindsight/client.test.ts +33 -0
- package/src/hindsight/client.ts +42 -22
- package/src/internal-urls/docs-index.generated.txt +1 -1
- package/src/main.ts +7 -1
- package/src/mcp/oauth-flow.ts +93 -4
- package/src/mcp/smithery-auth.ts +3 -0
- package/src/mcp/smithery-connect.ts +9 -0
- package/src/mcp/smithery-registry.test.ts +51 -0
- package/src/mcp/smithery-registry.ts +27 -4
- package/src/modes/components/advisor-message.ts +13 -10
- package/src/modes/components/status-line/component.ts +11 -4
- package/src/modes/components/tool-execution.ts +74 -8
- package/src/modes/components/transcript-container.ts +26 -0
- package/src/modes/controllers/event-controller.ts +7 -3
- package/src/modes/controllers/tool-args-reveal.ts +1 -1
- package/src/modes/interactive-mode.ts +1 -0
- package/src/modes/rpc/rpc-client.ts +29 -16
- package/src/modes/theme/shimmer.ts +49 -15
- package/src/modes/theme/theme.ts +7 -0
- package/src/session/agent-session.ts +166 -30
- package/src/session/indexed-session-storage.ts +40 -3
- package/src/session/session-manager.ts +83 -14
- package/src/session/session-storage.ts +112 -26
- package/src/slash-commands/helpers/usage-report.ts +6 -1
- package/src/ssh/__tests__/connection-manager-timeout.test.ts +61 -0
- package/src/ssh/__tests__/sshfs-mount.test.ts +13 -0
- package/src/ssh/connection-manager.ts +44 -11
- package/src/ssh/sshfs-mount.ts +27 -4
- package/src/subprocess/worker-client.ts +161 -10
- package/src/tools/bash.ts +30 -1
- package/src/tools/grep.ts +21 -1
- package/src/tools/read.ts +14 -4
- package/src/tools/renderers.ts +13 -0
- package/src/tools/ssh.ts +8 -0
- package/src/utils/clipboard.ts +49 -12
- package/src/utils/fetch-timeout.ts +10 -0
- package/src/web/search/index.ts +8 -0
- package/src/web/search/providers/base.ts +1 -0
- package/src/web/search/providers/gemini.ts +23 -6
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
1
3
|
import * as path from "node:path";
|
|
2
4
|
import {
|
|
3
5
|
$env,
|
|
@@ -64,7 +66,7 @@ export interface RefCountedWorkerHandle<Inbound, Outbound> extends WorkerHandle<
|
|
|
64
66
|
|
|
65
67
|
/** The raw spawned subprocess plus the parent-side fan-out sets. */
|
|
66
68
|
export interface SpawnedSubprocess<Outbound> {
|
|
67
|
-
proc: Subprocess<"ignore", "ignore", "ignore">;
|
|
69
|
+
proc: Subprocess<"ignore", "ignore", number | "ignore">;
|
|
68
70
|
inbound: Set<(message: Outbound) => void>;
|
|
69
71
|
errors: Set<(error: Error) => void>;
|
|
70
72
|
/**
|
|
@@ -74,8 +76,23 @@ export interface SpawnedSubprocess<Outbound> {
|
|
|
74
76
|
* worker error so callers don't await forever.
|
|
75
77
|
*/
|
|
76
78
|
intentionalExit: { value: boolean };
|
|
79
|
+
/**
|
|
80
|
+
* Resolves when the file-backed stderr capture has drained after worker
|
|
81
|
+
* exit. `onExit` waits on this before surfacing the crash so the exit-error
|
|
82
|
+
* carries the *whole* tail, not whatever happened to be flushed before the
|
|
83
|
+
* exit event fired. Tests can await it deterministically instead of racing
|
|
84
|
+
* wall-clock timers.
|
|
85
|
+
*/
|
|
86
|
+
stderrDrained: Promise<void>;
|
|
77
87
|
}
|
|
78
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Bound on the tail of worker stderr surfaced with a crash. Sized to comfortably
|
|
91
|
+
* hold a full ONNX Runtime/glibc traceback (a few KiB) without letting a chatty
|
|
92
|
+
* native runtime OOM the parent on repeated warnings.
|
|
93
|
+
*/
|
|
94
|
+
const STDERR_TAIL_LIMIT_BYTES = 16 * 1024;
|
|
95
|
+
|
|
79
96
|
export interface WorkerSpawnCommand {
|
|
80
97
|
cmd: string[];
|
|
81
98
|
cwd?: string;
|
|
@@ -126,11 +143,17 @@ export function workerEnvFromParent(overlay?: Record<string, string>): Record<st
|
|
|
126
143
|
}
|
|
127
144
|
|
|
128
145
|
/**
|
|
129
|
-
* Spawn an inference worker subprocess and wire its IPC fan-out.
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
146
|
+
* Spawn an inference worker subprocess and wire its IPC fan-out. Stdio is
|
|
147
|
+
* captured (stderr redirected to a temp file, stdout ignored) so native
|
|
148
|
+
* runtimes can't corrupt the chat scrollback while the crash reason still
|
|
149
|
+
* reaches the parent. The file-backed capture deliberately avoids Bun
|
|
150
|
+
* `ReadableStream` pipes: even an unref'd child with a piped stderr stream can
|
|
151
|
+
* keep the parent event loop alive. After the worker exits, the last
|
|
152
|
+
* {@link STDERR_TAIL_LIMIT_BYTES} are appended to the `onExit` error so
|
|
153
|
+
* `tts/mnemopi/…: worker error` lines carry the actual stack instead of a bare
|
|
154
|
+
* exit code (issue #4324). The child is `unref`'d outside `bun test` so an idle
|
|
155
|
+
* worker never blocks process exit. `exitLabel` prefixes the worker-error
|
|
156
|
+
* message surfaced for an unexpected (non-intentional) exit.
|
|
134
157
|
*/
|
|
135
158
|
export function createWorkerSubprocess<Outbound>(options: {
|
|
136
159
|
spawnCommand: WorkerSpawnCommand;
|
|
@@ -140,19 +163,29 @@ export function createWorkerSubprocess<Outbound>(options: {
|
|
|
140
163
|
const inbound = new Set<(message: Outbound) => void>();
|
|
141
164
|
const errors = new Set<(error: Error) => void>();
|
|
142
165
|
const intentionalExit = { value: false };
|
|
166
|
+
const stderrTail = new StderrTail(STDERR_TAIL_LIMIT_BYTES);
|
|
167
|
+
const stderrDrained = Promise.withResolvers<void>();
|
|
168
|
+
const stderrCapture = createStderrCapture(options.exitLabel);
|
|
169
|
+
let stderrDrainStarted = false;
|
|
170
|
+
const startStderrDrain = (): void => {
|
|
171
|
+
if (stderrDrainStarted) return;
|
|
172
|
+
stderrDrainStarted = true;
|
|
173
|
+
void drainStderrCapture(stderrCapture, options.exitLabel, stderrTail).finally(() => stderrDrained.resolve());
|
|
174
|
+
};
|
|
143
175
|
const proc = Bun.spawn({
|
|
144
176
|
cmd: options.spawnCommand.cmd,
|
|
145
177
|
cwd: options.spawnCommand.cwd,
|
|
146
178
|
env: options.env,
|
|
147
179
|
stdin: "ignore",
|
|
148
180
|
stdout: "ignore",
|
|
149
|
-
stderr:
|
|
181
|
+
stderr: stderrCapture.target,
|
|
150
182
|
serialization: "advanced",
|
|
151
183
|
windowsHide: true,
|
|
152
184
|
ipc(message) {
|
|
153
185
|
for (const handler of inbound) handler(message as Outbound);
|
|
154
186
|
},
|
|
155
187
|
onExit(_proc, exitCode, signalCode) {
|
|
188
|
+
startStderrDrain();
|
|
156
189
|
if (exitCode === 0) return;
|
|
157
190
|
// Swallow only the expected SIGKILL from `terminate()`; every other
|
|
158
191
|
// signal exit (SIGSEGV from a native fault, OOM SIGKILL, operator
|
|
@@ -160,15 +193,133 @@ export function createWorkerSubprocess<Outbound>(options: {
|
|
|
160
193
|
// requests so callers don't await forever.
|
|
161
194
|
if (exitCode === null && intentionalExit.value) return;
|
|
162
195
|
const reason = exitCode !== null ? `code ${exitCode}` : `signal ${signalCode ?? "unknown"}`;
|
|
163
|
-
|
|
164
|
-
|
|
196
|
+
// The stderr target is drained only after exit so idle unref'd
|
|
197
|
+
// workers do not keep the parent alive; wait for that drain before
|
|
198
|
+
// surfacing the error so the tail is complete.
|
|
199
|
+
void stderrDrained.promise.finally(() => {
|
|
200
|
+
const suffix = stderrTail.suffix();
|
|
201
|
+
const err = new Error(`${options.exitLabel} exited with ${reason}${suffix}`);
|
|
202
|
+
for (const handler of errors) handler(err);
|
|
203
|
+
});
|
|
165
204
|
},
|
|
166
205
|
});
|
|
167
206
|
// Don't keep the parent event loop alive on an idle worker; the dispose
|
|
168
207
|
// path calls `terminate()` explicitly. Bun's test runner starves IPC for
|
|
169
208
|
// unref'd subprocesses, so keep it referenced only under tests.
|
|
170
209
|
if (!isBunTestRuntime()) proc.unref();
|
|
171
|
-
return { proc, inbound, errors, intentionalExit };
|
|
210
|
+
return { proc, inbound, errors, intentionalExit, stderrDrained: stderrDrained.promise };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Bounded buffer of the *tail* of a stderr stream. Appended chunks are
|
|
215
|
+
* concatenated and truncated from the front once they exceed `limit`, so the
|
|
216
|
+
* final `suffix()` always reflects the most recent output — where native
|
|
217
|
+
* crash tracebacks land.
|
|
218
|
+
*/
|
|
219
|
+
class StderrTail {
|
|
220
|
+
#chunks: Uint8Array[] = [];
|
|
221
|
+
#bytes = 0;
|
|
222
|
+
constructor(readonly limit: number) {}
|
|
223
|
+
|
|
224
|
+
append(chunk: Uint8Array): void {
|
|
225
|
+
if (chunk.length === 0) return;
|
|
226
|
+
this.#chunks.push(chunk);
|
|
227
|
+
this.#bytes += chunk.length;
|
|
228
|
+
while (this.#bytes > this.limit && this.#chunks.length > 1) {
|
|
229
|
+
const head = this.#chunks.shift();
|
|
230
|
+
if (head) this.#bytes -= head.length;
|
|
231
|
+
}
|
|
232
|
+
if (this.#bytes > this.limit && this.#chunks.length === 1) {
|
|
233
|
+
const only = this.#chunks[0];
|
|
234
|
+
const start = only.length - this.limit;
|
|
235
|
+
this.#chunks[0] = only.subarray(start);
|
|
236
|
+
this.#bytes = this.limit;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Human-readable trailer for an exit error, or `""` when nothing was captured. */
|
|
241
|
+
suffix(): string {
|
|
242
|
+
if (this.#bytes === 0) return "";
|
|
243
|
+
const merged = new Uint8Array(this.#bytes);
|
|
244
|
+
let offset = 0;
|
|
245
|
+
for (const chunk of this.#chunks) {
|
|
246
|
+
merged.set(chunk, offset);
|
|
247
|
+
offset += chunk.length;
|
|
248
|
+
}
|
|
249
|
+
const text = new TextDecoder().decode(merged).replace(/\s+$/u, "");
|
|
250
|
+
if (text.length === 0) return "";
|
|
251
|
+
return `: ${text}`;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
interface StderrCapture {
|
|
256
|
+
target: number | "ignore";
|
|
257
|
+
fd: number | null;
|
|
258
|
+
dir: string | null;
|
|
259
|
+
cleanupOnExit: (() => void) | null;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** Create a file-backed stderr target that does not pin Bun's event loop. */
|
|
263
|
+
function createStderrCapture(exitLabel: string): StderrCapture {
|
|
264
|
+
try {
|
|
265
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "omp-worker-stderr-"));
|
|
266
|
+
const fd = fs.openSync(path.join(dir, "stderr.log"), "w+");
|
|
267
|
+
const cleanupOnExit = (): void => cleanupStderrCapture({ target: fd, fd, dir, cleanupOnExit: null });
|
|
268
|
+
process.once("exit", cleanupOnExit);
|
|
269
|
+
return { target: fd, fd, dir, cleanupOnExit };
|
|
270
|
+
} catch (error) {
|
|
271
|
+
logger.debug(`${exitLabel} stderr capture unavailable`, {
|
|
272
|
+
error: error instanceof Error ? error.message : String(error),
|
|
273
|
+
});
|
|
274
|
+
return { target: "ignore", fd: null, dir: null, cleanupOnExit: null };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function cleanupStderrCapture(capture: StderrCapture): void {
|
|
279
|
+
if (capture.cleanupOnExit) process.off("exit", capture.cleanupOnExit);
|
|
280
|
+
if (capture.fd !== null) {
|
|
281
|
+
try {
|
|
282
|
+
fs.closeSync(capture.fd);
|
|
283
|
+
} catch {
|
|
284
|
+
// Already closed.
|
|
285
|
+
}
|
|
286
|
+
capture.fd = null;
|
|
287
|
+
}
|
|
288
|
+
if (capture.dir) {
|
|
289
|
+
try {
|
|
290
|
+
fs.rmSync(capture.dir, { recursive: true, force: true });
|
|
291
|
+
} catch {
|
|
292
|
+
// Best-effort temp cleanup.
|
|
293
|
+
}
|
|
294
|
+
capture.dir = null;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Drain a worker's file-backed stderr target after it exits: forward each
|
|
300
|
+
* decoded tail line to `logger.debug`, and record the bytes in `tail` so the
|
|
301
|
+
* eventual exit error can carry the most recent output. Never rejects — cleanup
|
|
302
|
+
* failures must not fault the parent.
|
|
303
|
+
*/
|
|
304
|
+
async function drainStderrCapture(capture: StderrCapture, exitLabel: string, tail: StderrTail): Promise<void> {
|
|
305
|
+
try {
|
|
306
|
+
if (capture.fd === null) return;
|
|
307
|
+
const size = fs.fstatSync(capture.fd).size;
|
|
308
|
+
if (size <= 0) return;
|
|
309
|
+
const length = Math.min(size, tail.limit);
|
|
310
|
+
const buffer = new Uint8Array(length);
|
|
311
|
+
fs.readSync(capture.fd, buffer, 0, length, size - length);
|
|
312
|
+
tail.append(buffer);
|
|
313
|
+
for (const rawLine of new TextDecoder().decode(buffer).split("\n")) {
|
|
314
|
+
const line = rawLine.replace(/\r$/u, "");
|
|
315
|
+
if (line.length > 0) logger.debug(`${exitLabel} stderr`, { line });
|
|
316
|
+
}
|
|
317
|
+
} catch {
|
|
318
|
+
// The worker may have exited while the parent is already tearing down,
|
|
319
|
+
// or the temp file may have been removed by process-exit cleanup.
|
|
320
|
+
} finally {
|
|
321
|
+
cleanupStderrCapture(capture);
|
|
322
|
+
}
|
|
172
323
|
}
|
|
173
324
|
|
|
174
325
|
/**
|
package/src/tools/bash.ts
CHANGED
|
@@ -46,6 +46,33 @@ export const BASH_DEFAULT_PREVIEW_LINES = 10;
|
|
|
46
46
|
const BASH_ENV_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
47
47
|
const DEFAULT_AUTO_BACKGROUND_THRESHOLD_MS = 60_000;
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Shape a shell command line for an ACP-conformant `terminal/create` request.
|
|
51
|
+
*
|
|
52
|
+
* ACP's `command` field is documented as the executable and `args` as its
|
|
53
|
+
* argv tail (see https://agentclientprotocol.com/protocol/v1/terminals), so a
|
|
54
|
+
* spec-conformant client `spawn(command, args)`s them directly — no implicit
|
|
55
|
+
* shell. A raw `bash` tool line ("git status && echo x | head") therefore has
|
|
56
|
+
* to be wrapped in an explicit shell invocation, otherwise the client tries
|
|
57
|
+
* to spawn the whole line as argv[0] and fails with `ENOENT` for anything
|
|
58
|
+
* containing a space, pipe, `&&`, redirect, or `$(...)`.
|
|
59
|
+
*
|
|
60
|
+
* The wrap reuses the same shell binary + args the local `bash-executor` would
|
|
61
|
+
* pick via `settings.getShellConfig()` — Git Bash / `bash.exe` on Windows,
|
|
62
|
+
* `$SHELL` (bash/zsh) with the `sh` fallback on POSIX — so the ACP path
|
|
63
|
+
* preserves `bash` tool semantics (`$VAR`, `$(...)`, `source`, POSIX quoting,
|
|
64
|
+
* `-l`) instead of dropping to `cmd.exe` on Windows. The agent host's shell
|
|
65
|
+
* path is used as a proxy for the client's, matching the near-universal
|
|
66
|
+
* ACP deployment shape of an editor spawning omp as a co-hosted subprocess.
|
|
67
|
+
*/
|
|
68
|
+
export function wrapShellLineForClientTerminal(
|
|
69
|
+
line: string,
|
|
70
|
+
shellConfig: { shell: string; args: string[]; prefix?: string | undefined },
|
|
71
|
+
): { command: string; args: string[] } {
|
|
72
|
+
const finalLine = shellConfig.prefix ? `${shellConfig.prefix} ${line}` : line;
|
|
73
|
+
return { command: shellConfig.shell, args: [...shellConfig.args, finalLine] };
|
|
74
|
+
}
|
|
75
|
+
|
|
49
76
|
/**
|
|
50
77
|
* Bash patterns flagged as safety critical for approval policy.
|
|
51
78
|
*
|
|
@@ -842,8 +869,10 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
|
|
|
842
869
|
// Skip when pty=true (PTY needs the local terminal UI).
|
|
843
870
|
if (clientBridge?.capabilities.terminal && clientBridge.createTerminal && !pty) {
|
|
844
871
|
const bridgeWallTimeStart = performance.now();
|
|
872
|
+
const shellSpawn = wrapShellLineForClientTerminal(command, this.session.settings.getShellConfig());
|
|
845
873
|
const handle = await clientBridge.createTerminal({
|
|
846
|
-
command,
|
|
874
|
+
command: shellSpawn.command,
|
|
875
|
+
args: shellSpawn.args,
|
|
847
876
|
cwd: commandCwd,
|
|
848
877
|
env: resolvedEnv
|
|
849
878
|
? Object.entries(resolvedEnv).map(([name, value]) => ({ name, value: value as string }))
|
package/src/tools/grep.ts
CHANGED
|
@@ -1371,6 +1371,17 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
|
|
1371
1371
|
}, 0);
|
|
1372
1372
|
let lastEmittedLine: number | undefined;
|
|
1373
1373
|
const gutterPad = " ".repeat(lineNumberWidth + 1);
|
|
1374
|
+
// Track match/context lines whose displayed text was
|
|
1375
|
+
// column-truncated by the native (see `crates/pi-natives/src/grep.rs`
|
|
1376
|
+
// `truncate_line`, marker `...` at max_columns). Excluded from
|
|
1377
|
+
// seenLines so a follow-up edit anchored at that line still
|
|
1378
|
+
// requires a full-width re-read — the model saw only the
|
|
1379
|
+
// prefix. The native currently propagates `truncated` only on
|
|
1380
|
+
// the match line; context lines fall back to a length check
|
|
1381
|
+
// against `DEFAULT_MAX_COLUMN` as a conservative heuristic.
|
|
1382
|
+
const clippedLines = new Set<number>();
|
|
1383
|
+
const isNativeTruncated = (line: string): boolean =>
|
|
1384
|
+
line.length >= DEFAULT_MAX_COLUMN && line.endsWith("...");
|
|
1374
1385
|
for (const match of fileMatches) {
|
|
1375
1386
|
const pushLine = (lineNumber: number, line: string, isMatch: boolean) => {
|
|
1376
1387
|
if (lastEmittedLine !== undefined && lineNumber > lastEmittedLine + 1) {
|
|
@@ -1384,22 +1395,31 @@ export class GrepTool implements AgentTool<typeof searchSchema, GrepToolDetails>
|
|
|
1384
1395
|
if (match.contextBefore) {
|
|
1385
1396
|
for (const ctx of match.contextBefore) {
|
|
1386
1397
|
pushLine(ctx.lineNumber, ctx.line, false);
|
|
1398
|
+
if (isNativeTruncated(ctx.line)) clippedLines.add(ctx.lineNumber);
|
|
1387
1399
|
}
|
|
1388
1400
|
}
|
|
1389
1401
|
pushLine(match.lineNumber, match.line, true);
|
|
1390
1402
|
if (match.truncated) {
|
|
1391
1403
|
linesTruncated = true;
|
|
1404
|
+
clippedLines.add(match.lineNumber);
|
|
1392
1405
|
}
|
|
1393
1406
|
if (match.contextAfter) {
|
|
1394
1407
|
for (const ctx of match.contextAfter) {
|
|
1395
1408
|
pushLine(ctx.lineNumber, ctx.line, false);
|
|
1409
|
+
if (isNativeTruncated(ctx.line)) clippedLines.add(ctx.lineNumber);
|
|
1396
1410
|
}
|
|
1397
1411
|
}
|
|
1398
1412
|
fileMatchCounts.set(relativePath, (fileMatchCounts.get(relativePath) ?? 0) + 1);
|
|
1399
1413
|
}
|
|
1400
1414
|
if (hashContext?.tag) {
|
|
1401
1415
|
const absoluteFilePath = path.resolve(this.session.cwd, relativePath);
|
|
1402
|
-
recordSeenLinesFromBody(
|
|
1416
|
+
recordSeenLinesFromBody(
|
|
1417
|
+
this.session,
|
|
1418
|
+
absoluteFilePath,
|
|
1419
|
+
hashContext.tag,
|
|
1420
|
+
modelOut.join("\n"),
|
|
1421
|
+
clippedLines,
|
|
1422
|
+
);
|
|
1403
1423
|
}
|
|
1404
1424
|
return { model: modelOut, display: displayOut };
|
|
1405
1425
|
};
|
package/src/tools/read.ts
CHANGED
|
@@ -1559,6 +1559,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1559
1559
|
const displayLineByNumber = new Map<number, string>();
|
|
1560
1560
|
const fullLines = rawSelector ? undefined : await readBracketContextFullLines(absolutePath, fileSize);
|
|
1561
1561
|
let columnTruncated = 0;
|
|
1562
|
+
const clippedLines = new Set<number>();
|
|
1562
1563
|
let displayContent: { text: string; startLine: number; lineNumbers?: Array<number | null> } | undefined;
|
|
1563
1564
|
|
|
1564
1565
|
for (const range of ranges) {
|
|
@@ -1606,6 +1607,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1606
1607
|
if (!cloned) cloned = collectedLines.slice();
|
|
1607
1608
|
cloned[i] = text;
|
|
1608
1609
|
columnTruncated = maxColumns;
|
|
1610
|
+
clippedLines.add(range.startLine + i);
|
|
1609
1611
|
}
|
|
1610
1612
|
}
|
|
1611
1613
|
if (cloned) displayLines = cloned;
|
|
@@ -1633,7 +1635,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1633
1635
|
if (visibleText !== undefined) return visibleText;
|
|
1634
1636
|
if (maxColumns <= 0) return sourceText;
|
|
1635
1637
|
const truncated = truncateLine(sourceText, maxColumns);
|
|
1636
|
-
if (truncated.wasTruncated)
|
|
1638
|
+
if (truncated.wasTruncated) {
|
|
1639
|
+
columnTruncated = maxColumns;
|
|
1640
|
+
clippedLines.add(lineNumber);
|
|
1641
|
+
}
|
|
1637
1642
|
return truncated.text;
|
|
1638
1643
|
},
|
|
1639
1644
|
},
|
|
@@ -1651,7 +1656,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
1651
1656
|
if (shouldAddHashLines && outputText) {
|
|
1652
1657
|
const tag = await recordFileSnapshot(this.session, absolutePath);
|
|
1653
1658
|
if (tag) {
|
|
1654
|
-
recordSeenLinesFromBody(this.session, absolutePath, tag, outputText);
|
|
1659
|
+
recordSeenLinesFromBody(this.session, absolutePath, tag, outputText, clippedLines);
|
|
1655
1660
|
outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
|
|
1656
1661
|
}
|
|
1657
1662
|
}
|
|
@@ -2490,6 +2495,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2490
2495
|
// ellipsis-truncated text made every long-line file uneditable on
|
|
2491
2496
|
// the next edit attempt.
|
|
2492
2497
|
let displayLines: string[] = collectedLines;
|
|
2498
|
+
const clippedLines = new Set<number>();
|
|
2493
2499
|
if (!rawSelector && maxColumns > 0) {
|
|
2494
2500
|
let cloned: string[] | undefined;
|
|
2495
2501
|
for (let i = 0; i < collectedLines.length; i++) {
|
|
@@ -2498,6 +2504,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2498
2504
|
if (!cloned) cloned = collectedLines.slice();
|
|
2499
2505
|
cloned[i] = text;
|
|
2500
2506
|
columnTruncated = maxColumns;
|
|
2507
|
+
clippedLines.add(startLineDisplay + i);
|
|
2501
2508
|
}
|
|
2502
2509
|
}
|
|
2503
2510
|
if (cloned) displayLines = cloned;
|
|
@@ -2580,7 +2587,10 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2580
2587
|
if (visibleText !== undefined) return visibleText;
|
|
2581
2588
|
if (maxColumns <= 0) return sourceText;
|
|
2582
2589
|
const truncated = truncateLine(sourceText, maxColumns);
|
|
2583
|
-
if (truncated.wasTruncated)
|
|
2590
|
+
if (truncated.wasTruncated) {
|
|
2591
|
+
columnTruncated = maxColumns;
|
|
2592
|
+
clippedLines.add(lineNumber);
|
|
2593
|
+
}
|
|
2584
2594
|
return truncated.text;
|
|
2585
2595
|
},
|
|
2586
2596
|
},
|
|
@@ -2654,7 +2664,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
|
|
|
2654
2664
|
}
|
|
2655
2665
|
|
|
2656
2666
|
if (hashContext?.tag) {
|
|
2657
|
-
recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText);
|
|
2667
|
+
recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText, clippedLines);
|
|
2658
2668
|
}
|
|
2659
2669
|
|
|
2660
2670
|
if (capturedDisplayContent) {
|
package/src/tools/renderers.ts
CHANGED
|
@@ -74,6 +74,19 @@ export type ToolRenderer = {
|
|
|
74
74
|
* `options.spinnerFrame`.
|
|
75
75
|
*/
|
|
76
76
|
animatedPartialResult?: boolean | ((args: unknown) => boolean);
|
|
77
|
+
/**
|
|
78
|
+
* Whether replacing a streamed pending placeholder with the first result
|
|
79
|
+
* requires a full viewport repaint. Use for merged renderers whose pending
|
|
80
|
+
* streamed args may have committed placeholder rows that the result render
|
|
81
|
+
* re-anchors instead of preserving.
|
|
82
|
+
*/
|
|
83
|
+
forceFirstResultViewportRepaint?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Whether settling a provisional partial result into the final render requires
|
|
86
|
+
* a full viewport repaint. Use when the result renderer changes chrome or
|
|
87
|
+
* frame topology at `options.isPartial: true -> false`.
|
|
88
|
+
*/
|
|
89
|
+
forceResultViewportRepaintOnSettle?: boolean;
|
|
77
90
|
};
|
|
78
91
|
|
|
79
92
|
export const toolRenderers: Record<string, ToolRenderer> = {
|
package/src/tools/ssh.ts
CHANGED
|
@@ -402,4 +402,12 @@ export const sshToolRenderer = {
|
|
|
402
402
|
// land below and strand a duplicate pending header above the final frame
|
|
403
403
|
// ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)).
|
|
404
404
|
provisionalPartialResult: true,
|
|
405
|
+
// Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` /
|
|
406
|
+
// `$ …`), then the first partial result inserts the `Output` section and
|
|
407
|
+
// re-anchors the frame. Force a full repaint at that seam so placeholder rows
|
|
408
|
+
// do not survive in viewport/native scrollback.
|
|
409
|
+
forceFirstResultViewportRepaint: true,
|
|
410
|
+
// The provisional pending-result frame settles into the final `⇄ SSH: [host]`
|
|
411
|
+
// frame, so clear/replay the viewport at that topology flip too.
|
|
412
|
+
forceResultViewportRepaintOnSettle: true,
|
|
405
413
|
};
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -1,8 +1,49 @@
|
|
|
1
|
-
import { execSync } from "node:child_process";
|
|
2
1
|
import type { ClipboardImage } from "@oh-my-pi/pi-natives";
|
|
3
2
|
import * as native from "@oh-my-pi/pi-natives";
|
|
4
3
|
import { logger } from "@oh-my-pi/pi-utils";
|
|
5
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Run a subprocess and capture its stdout without blocking the event loop.
|
|
7
|
+
*
|
|
8
|
+
* `readTextFromClipboard`, `readMacFileUrlsFromClipboard`, and the Termux copy
|
|
9
|
+
* path all shell out to CLI clipboard tools. The synchronous `execSync` API
|
|
10
|
+
* parks the render loop until the child exits or the timeout fires, so a hung
|
|
11
|
+
* clipboard daemon freezes the TUI for the full 2000ms budget (#4235). This
|
|
12
|
+
* helper mirrors the previous semantics — read stdout as UTF-8, throw on
|
|
13
|
+
* non-zero exit or timeout, forward optional stdin — but yields to the event
|
|
14
|
+
* loop while the child runs.
|
|
15
|
+
*
|
|
16
|
+
* @throws Error when the child fails to spawn, is killed by the timeout, or
|
|
17
|
+
* exits with a non-zero status. Callers rely on this to fall through to the
|
|
18
|
+
* outer catch and return an empty string / empty list.
|
|
19
|
+
*/
|
|
20
|
+
async function spawnCapture(cmd: string[], options: { input?: string; timeoutMs?: number } = {}): Promise<string> {
|
|
21
|
+
const timeoutMs = options.timeoutMs ?? 2000;
|
|
22
|
+
const proc = Bun.spawn(cmd, {
|
|
23
|
+
stdout: "pipe",
|
|
24
|
+
stderr: "ignore",
|
|
25
|
+
stdin: options.input !== undefined ? Buffer.from(options.input) : "ignore",
|
|
26
|
+
});
|
|
27
|
+
let timedOut = false;
|
|
28
|
+
const timer = setTimeout(() => {
|
|
29
|
+
timedOut = true;
|
|
30
|
+
proc.kill();
|
|
31
|
+
}, timeoutMs);
|
|
32
|
+
try {
|
|
33
|
+
const stdout = await new Response(proc.stdout).text();
|
|
34
|
+
await proc.exited;
|
|
35
|
+
if (timedOut) {
|
|
36
|
+
throw new Error(`${cmd[0]} timed out after ${timeoutMs}ms`);
|
|
37
|
+
}
|
|
38
|
+
if (proc.exitCode !== 0) {
|
|
39
|
+
throw new Error(`${cmd[0]} exited with code ${proc.exitCode}`);
|
|
40
|
+
}
|
|
41
|
+
return stdout;
|
|
42
|
+
} finally {
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
6
47
|
function hasDisplay(): boolean {
|
|
7
48
|
return process.platform !== "linux" || Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
8
49
|
}
|
|
@@ -53,11 +94,7 @@ const MAC_FILE_URL_SCRIPT = [
|
|
|
53
94
|
export async function readMacFileUrlsFromClipboard(): Promise<string[]> {
|
|
54
95
|
if (process.platform !== "darwin") return [];
|
|
55
96
|
try {
|
|
56
|
-
const stdout =
|
|
57
|
-
input: MAC_FILE_URL_SCRIPT,
|
|
58
|
-
encoding: "utf8",
|
|
59
|
-
timeout: 2000,
|
|
60
|
-
}).toString();
|
|
97
|
+
const stdout = await spawnCapture(["osascript", "-"], { input: MAC_FILE_URL_SCRIPT });
|
|
61
98
|
return stdout
|
|
62
99
|
.split(/\r?\n/)
|
|
63
100
|
.map(line => line.trim())
|
|
@@ -110,7 +147,7 @@ export async function copyToClipboard(text: string): Promise<void> {
|
|
|
110
147
|
try {
|
|
111
148
|
if (process.env.TERMUX_VERSION) {
|
|
112
149
|
try {
|
|
113
|
-
|
|
150
|
+
await spawnCapture(["termux-clipboard-set"], { input: text, timeoutMs: 5000 });
|
|
114
151
|
return;
|
|
115
152
|
} catch {
|
|
116
153
|
// Fall through to native
|
|
@@ -286,13 +323,13 @@ export async function readTextFromClipboard(): Promise<string> {
|
|
|
286
323
|
try {
|
|
287
324
|
const p = process.platform;
|
|
288
325
|
if (p === "darwin") {
|
|
289
|
-
return
|
|
326
|
+
return await spawnCapture(["pbpaste"]);
|
|
290
327
|
}
|
|
291
328
|
if (p === "win32") {
|
|
292
329
|
return (await readTextViaPowerShell()) ?? "";
|
|
293
330
|
}
|
|
294
331
|
if (process.env.TERMUX_VERSION) {
|
|
295
|
-
return
|
|
332
|
+
return await spawnCapture(["termux-clipboard-get"]);
|
|
296
333
|
}
|
|
297
334
|
if (isWsl()) {
|
|
298
335
|
const text = await readTextViaPowerShell();
|
|
@@ -303,14 +340,14 @@ export async function readTextFromClipboard(): Promise<string> {
|
|
|
303
340
|
const hasX11Display = Boolean(process.env.DISPLAY);
|
|
304
341
|
if (hasWaylandDisplay) {
|
|
305
342
|
try {
|
|
306
|
-
return
|
|
343
|
+
return await spawnCapture(["wl-paste", "--type", "text/plain", "--no-newline"]);
|
|
307
344
|
} catch {
|
|
308
345
|
if (hasX11Display) {
|
|
309
|
-
return
|
|
346
|
+
return await spawnCapture(["xclip", "-selection", "clipboard", "-o"]);
|
|
310
347
|
}
|
|
311
348
|
}
|
|
312
349
|
} else if (hasX11Display) {
|
|
313
|
-
return
|
|
350
|
+
return await spawnCapture(["xclip", "-selection", "clipboard", "-o"]);
|
|
314
351
|
}
|
|
315
352
|
} catch (error) {
|
|
316
353
|
logger.warn("clipboard: failed to read clipboard text", { error: String(error) });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** Create an abort signal that fires after a timeout and preserves caller cancellation. */
|
|
2
|
+
export function withTimeoutSignal(timeoutMs: number, signal?: AbortSignal): AbortSignal {
|
|
3
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
4
|
+
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** Detect a timeout raised by an abortable fetch. */
|
|
8
|
+
export function isTimeoutError(error: unknown): boolean {
|
|
9
|
+
return error instanceof Error && error.name === "TimeoutError";
|
|
10
|
+
}
|
package/src/web/search/index.ts
CHANGED
|
@@ -159,6 +159,13 @@ async function executeSearch(
|
|
|
159
159
|
antigravityEndpointMode = undefined;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
let geminiModel: string | undefined;
|
|
163
|
+
try {
|
|
164
|
+
geminiModel = settings.get("providers.webSearchGeminiModel");
|
|
165
|
+
} catch {
|
|
166
|
+
geminiModel = undefined;
|
|
167
|
+
}
|
|
168
|
+
|
|
162
169
|
const failures: Array<{ provider: SearchProvider; error: unknown }> = [];
|
|
163
170
|
let lastProvider = providers[0];
|
|
164
171
|
for (const provider of providers) {
|
|
@@ -176,6 +183,7 @@ async function executeSearch(
|
|
|
176
183
|
authStorage,
|
|
177
184
|
sessionId,
|
|
178
185
|
antigravityEndpointMode,
|
|
186
|
+
geminiModel,
|
|
179
187
|
});
|
|
180
188
|
|
|
181
189
|
if (!hasRenderableSearchContent(response)) {
|