pi-codex-tools 0.1.0 → 0.1.2
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 +16 -0
- package/README.md +6 -3
- package/SECURITY.md +2 -2
- package/extensions/index.ts +42 -0
- package/package.json +7 -5
- package/src/apply-patch.ts +83 -51
- package/src/install-telemetry.ts +8 -7
- package/src/patch-preview.ts +265 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.2] - 2026-08-06
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Stream `apply_patch` progress in the TUI: while a patch is generated the tool now shows a live diff glimpse of the content being written plus a running `+added -removed` tally (and a capped per-file roster for multi-file patches), reusing Pi's shared diff rendering. Moves render the source → destination transition, and the preview is byte/file bounded for responsiveness. Execution behavior is unchanged.
|
|
14
|
+
|
|
15
|
+
## [0.1.1] - 2026-08-04
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Guard `apply_patch` execution when the active model does not advertise grammar-tool support.
|
|
20
|
+
- Require the Pi runtime grammar-tool contract and fail closed on unsupported filesystem platforms.
|
|
21
|
+
- Preflight repeated file hunks sequentially without rejecting valid Codex patches.
|
|
22
|
+
- Respect `enableInstallTelemetry: false` even when `PI_TELEMETRY` is enabled.
|
|
23
|
+
- Document credential-containing target reads, telemetry controls, runtime requirements, and line-oriented compatibility.
|
|
24
|
+
|
|
9
25
|
## [0.1.0] - 2026-08-03
|
|
10
26
|
|
|
11
27
|
### Added
|
package/README.md
CHANGED
|
@@ -6,9 +6,10 @@ Give grammar-capable OpenAI/Codex models the Codex `apply_patch` tool in Pi with
|
|
|
6
6
|
|
|
7
7
|
- **Raw `apply_patch`** — sends Codex's Lark grammar as an OpenAI custom tool, so patches are not JSON-wrapped.
|
|
8
8
|
- **Capability-based activation** — requires `openai-codex-responses` or `openai-responses` plus `model.compat.supportsOpenAIGrammarTools === true`; model names alone are never enough.
|
|
9
|
-
- **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, stay under Pi's current working directory, reject symlink escapes, use descriptor-anchored no-follow operations on Linux
|
|
9
|
+
- **Safe local mutation** — patches are limited to 1 MiB, target files to 64 MiB, stay under Pi's current working directory, reject symlink escapes, use descriptor-anchored no-follow operations on Linux, fail closed elsewhere, preflight all hunks, and serialize writes with Pi's mutation queue.
|
|
10
10
|
- **Model switching** — supported models replace Pi's `edit` and `write` tools with `apply_patch`; other active tools are preserved. Switching back restores only the file tools that were active before the switch.
|
|
11
11
|
- **Sequential patch calls** — the extension marks patch execution sequential and disables provider-side parallel tool calls when the patch tool is active.
|
|
12
|
+
- **Streaming progress** — while a patch is generated, the TUI shows a live, color-coded glimpse of the content being written (new-file content, or `+`/`-` lines for updates) plus a running `+added -removed` tally and a per-file roster for multi-file patches. It reuses Pi's shared diff rendering and mirrors the built-in `write`/`edit` previews; patch execution is unchanged.
|
|
12
13
|
|
|
13
14
|
## Installation
|
|
14
15
|
|
|
@@ -24,7 +25,7 @@ pi -e /path/to/pi-mono/packages/pi-codex-tools
|
|
|
24
25
|
|
|
25
26
|
## Scope decisions
|
|
26
27
|
|
|
27
|
-
The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Because Pi does not provide Codex's OS-level filesystem sandbox, `apply_patch` runs only on Linux
|
|
28
|
+
The current Codex source does not define separate `read_file` or `write_file` tools: file inspection is normally done through shell commands and file mutation through `apply_patch`. This package keeps Pi's bounded `read` and `bash` tools, and uses `apply_patch` in place of Pi's `edit` and `write` tools for supported models. Because Pi does not provide Codex's OS-level filesystem sandbox, `apply_patch` runs only on Linux and fails closed on unsupported platforms. It also requires a Pi model runtime that advertises `compat.supportsOpenAIGrammarTools`; older runtimes leave the tool inactive.
|
|
28
29
|
|
|
29
30
|
| Codex surface | Decision |
|
|
30
31
|
| --- | --- |
|
|
@@ -48,6 +49,8 @@ These choices are based on the Codex tool specifications in `codex-rs/core/src/t
|
|
|
48
49
|
|
|
49
50
|
These behaviors intentionally match Codex `apply_patch`.
|
|
50
51
|
|
|
52
|
+
The provider contract is runtime-specific: use Pi 0.83.0 or newer for OpenAI grammar-tool support. For a manual smoke test, start Pi with this extension and a model that advertises `supportsOpenAIGrammarTools`, then verify that a file change appears as an `apply_patch` call and not as `edit`, `write`, or `bash`.
|
|
53
|
+
|
|
51
54
|
## Development
|
|
52
55
|
|
|
53
56
|
```bash
|
|
@@ -56,7 +59,7 @@ npm test -w packages/pi-codex-tools
|
|
|
56
59
|
npm run -w packages/pi-codex-tools pack:dry-run
|
|
57
60
|
```
|
|
58
61
|
|
|
59
|
-
Install/update telemetry can be disabled with `PI_OFFLINE=1` or `PI_TELEMETRY=
|
|
62
|
+
Install/update telemetry is disabled in CI and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0` or `PI_TELEMETRY=false`, or Pi's `enableInstallTelemetry: false` setting. See [SECURITY.md](./SECURITY.md).
|
|
60
63
|
|
|
61
64
|
## License
|
|
62
65
|
|
package/SECURITY.md
CHANGED
|
@@ -19,8 +19,8 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
|
|
|
19
19
|
|
|
20
20
|
Pi extensions execute with the same permissions as the local user running Pi. Review installed extensions and only install packages from sources you trust.
|
|
21
21
|
|
|
22
|
-
`apply_patch` does not access the network or
|
|
22
|
+
`apply_patch` does not access the network or credential APIs. It can read credential-containing files when a patch targets them. It validates paths beneath the current working directory, rejects symlink paths and symlinked parents, limits patch input to 1 MiB and target-file reads to 64 MiB, preflights file changes before writing, and uses root-anchored descriptor-based no-follow operations on Linux. It fails closed on unsupported platforms because Pi does not provide Codex's OS-level filesystem sandbox. A failure during a multi-file write can still leave earlier files changed; callers should use version control and review the resulting diff.
|
|
23
23
|
|
|
24
24
|
The package reads the current provider/model capability flags only to select tools. It does not log prompts, patches, file contents, credentials, auth headers, or provider responses.
|
|
25
25
|
|
|
26
|
-
Install/update telemetry is best effort and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, CI detection, or Pi's `enableInstallTelemetry: false` setting.
|
|
26
|
+
Install/update telemetry is best effort and can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, `PI_TELEMETRY=false`, CI detection, or Pi's `enableInstallTelemetry: false` setting. Through `@mocito/install-telemetry`, it sends the package name and version as HTTPS URL query parameters and adds `process.platform`, the runtime name/version, and `process.arch` to the `User-Agent`. These fields are not intended to identify a host, user, repository, or path; no prompts, patches, file contents, credentials, auth headers, or provider responses are sent.
|
package/extensions/index.ts
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
+
import { Container, Text } from "@earendil-works/pi-tui";
|
|
2
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
1
3
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
4
|
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
3
5
|
import { applyPatch, APPLY_PATCH_GRAMMAR, MAX_PATCH_BYTES } from "../src/apply-patch.js";
|
|
4
6
|
import { createFreeformInputSchema, createOpenAILarkSampling, type OpenAIGrammarSampling } from "../src/grammar.js";
|
|
5
7
|
import { supportsOpenAIGrammarTools } from "../src/model-support.js";
|
|
8
|
+
import { formatApplyPatchCallText, formatApplyPatchResultText } from "../src/patch-preview.js";
|
|
9
|
+
|
|
10
|
+
class ApplyPatchCallComponent extends Text {
|
|
11
|
+
cache?: { key: string; text: string };
|
|
12
|
+
constructor() {
|
|
13
|
+
super("", 0, 0);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readPatchArg(args: unknown): string {
|
|
18
|
+
return typeof (args as { patch?: unknown })?.patch === "string" ? (args as { patch: string }).patch : "";
|
|
19
|
+
}
|
|
6
20
|
|
|
7
21
|
const APPLY_PATCH = "apply_patch";
|
|
8
22
|
const EDIT = "edit";
|
|
@@ -35,7 +49,35 @@ export default function piCodexTools(pi: ExtensionAPI): void {
|
|
|
35
49
|
parameters: APPLY_PATCH_PARAMETERS,
|
|
36
50
|
constrainedSampling: createOpenAILarkSampling(APPLY_PATCH_GRAMMAR),
|
|
37
51
|
executionMode: "sequential",
|
|
52
|
+
renderCall(args, theme, context) {
|
|
53
|
+
const component =
|
|
54
|
+
context.lastComponent instanceof ApplyPatchCallComponent ? context.lastComponent : new ApplyPatchCallComponent();
|
|
55
|
+
const rawPatch = readPatchArg(args);
|
|
56
|
+
const key = `${context.expanded ? "1" : "0"}:${rawPatch}`;
|
|
57
|
+
if (!component.cache || component.cache.key !== key) {
|
|
58
|
+
component.cache = {
|
|
59
|
+
key,
|
|
60
|
+
text: formatApplyPatchCallText(rawPatch, theme, { expanded: context.expanded }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
component.setText(component.cache.text);
|
|
64
|
+
return component as Component;
|
|
65
|
+
},
|
|
66
|
+
renderResult(result, _options, theme, context) {
|
|
67
|
+
const text = formatApplyPatchResultText(result, theme, context.isError);
|
|
68
|
+
if (!text) {
|
|
69
|
+
const component = (context.lastComponent ?? new Container()) as Container;
|
|
70
|
+
component.clear();
|
|
71
|
+
return component as Component;
|
|
72
|
+
}
|
|
73
|
+
const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
74
|
+
component.setText(text);
|
|
75
|
+
return component as Component;
|
|
76
|
+
},
|
|
38
77
|
async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
|
|
78
|
+
if (!supportsOpenAIGrammarTools(ctx.model)) {
|
|
79
|
+
throw new Error("apply_patch is only available for OpenAI models that advertise grammar-tool support.");
|
|
80
|
+
}
|
|
39
81
|
const patch = (rawParams as { patch?: unknown }).patch;
|
|
40
82
|
if (typeof patch !== "string") throw new Error("apply_patch requires raw patch text.");
|
|
41
83
|
const result = await applyPatch(patch, { cwd: ctx.cwd, signal });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-tools",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Codex-compatible apply_patch tooling for Pi's grammar-capable OpenAI models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -52,14 +52,16 @@
|
|
|
52
52
|
"peerDependencies": {
|
|
53
53
|
"@earendil-works/pi-ai": "*",
|
|
54
54
|
"@earendil-works/pi-coding-agent": "*",
|
|
55
|
+
"@earendil-works/pi-tui": "*",
|
|
55
56
|
"typebox": "*"
|
|
56
57
|
},
|
|
57
58
|
"devDependencies": {
|
|
58
|
-
"@earendil-works/pi-ai": "^0.
|
|
59
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
60
|
-
"@
|
|
59
|
+
"@earendil-works/pi-ai": "^0.82.1",
|
|
60
|
+
"@earendil-works/pi-coding-agent": "^0.82.1",
|
|
61
|
+
"@earendil-works/pi-tui": "^0.82.1",
|
|
62
|
+
"@types/node": "^26.1.2",
|
|
61
63
|
"tsx": "^4.23.1",
|
|
62
|
-
"typebox": "^1.3.
|
|
64
|
+
"typebox": "^1.3.8",
|
|
63
65
|
"typescript": "^7.0.2"
|
|
64
66
|
},
|
|
65
67
|
"publishConfig": {
|
package/src/apply-patch.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const MAX_PATCH_HUNKS = 1_000;
|
|
|
10
10
|
export const MAX_TARGET_FILE_BYTES = 64 * 1024 * 1024;
|
|
11
11
|
|
|
12
12
|
const O_NOFOLLOW = constants.O_NOFOLLOW ?? 0;
|
|
13
|
-
const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" :
|
|
13
|
+
const SECURE_FD_DIRECTORY = process.platform === "linux" ? "/proc/self/fd" : undefined;
|
|
14
14
|
const SECURE_FILESYSTEM_SUPPORTED = SECURE_FD_DIRECTORY !== undefined && O_NOFOLLOW !== 0;
|
|
15
15
|
const SECURE_DIRECTORY_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_DIRECTORY ?? 0) | (constants.O_NONBLOCK ?? 0);
|
|
16
16
|
const SECURE_READ_FLAGS = constants.O_RDONLY | O_NOFOLLOW | (constants.O_NONBLOCK ?? 0);
|
|
@@ -225,6 +225,7 @@ type PlannedOperation =
|
|
|
225
225
|
| { kind: "update"; path: string; displayPath: string; moveTo?: string; moveDisplayPath?: string; chunkGroups: UpdateChunk[][]; content: string };
|
|
226
226
|
|
|
227
227
|
type SafePath = { absolute: string; exists: boolean; isDirectory: boolean; isFile: boolean };
|
|
228
|
+
type VirtualFile = Omit<SafePath, "absolute"> & { content?: string };
|
|
228
229
|
|
|
229
230
|
export async function applyPatch(input: string, options: ApplyPatchOptions): Promise<ApplyPatchResult> {
|
|
230
231
|
requireSecureFilesystem();
|
|
@@ -271,77 +272,108 @@ export async function applyPatch(input: string, options: ApplyPatchOptions): Pro
|
|
|
271
272
|
|
|
272
273
|
async function planOperations(hunks: ApplyPatchHunk[], root: string, rootHandle: FileHandle, signal?: AbortSignal): Promise<PlannedOperation[]> {
|
|
273
274
|
const operations: PlannedOperation[] = [];
|
|
274
|
-
const
|
|
275
|
-
|
|
275
|
+
const virtualFiles = new Map<string, VirtualFile>();
|
|
276
|
+
|
|
277
|
+
const getVirtualFile = async (rawPath: string): Promise<{ absolute: string; file: VirtualFile }> => {
|
|
278
|
+
const absolute = resolvePatchPath(rawPath, root);
|
|
279
|
+
const existing = virtualFiles.get(absolute);
|
|
280
|
+
if (existing) return { absolute, file: existing };
|
|
281
|
+
const safe = await safePath(rawPath, root, signal);
|
|
282
|
+
const file: VirtualFile = {
|
|
283
|
+
exists: safe.exists,
|
|
284
|
+
isDirectory: safe.isDirectory,
|
|
285
|
+
isFile: safe.isFile,
|
|
286
|
+
};
|
|
287
|
+
virtualFiles.set(absolute, file);
|
|
288
|
+
return { absolute, file };
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
const getVirtualContent = async (absolute: string, file: VirtualFile): Promise<string> => {
|
|
292
|
+
if (!file.exists || !file.isFile) throw new Error(`Cannot read non-file '${absolute}'.`);
|
|
293
|
+
if (file.content === undefined) file.content = await readSecureFile(rootHandle, root, absolute, signal);
|
|
294
|
+
return file.content;
|
|
295
|
+
};
|
|
276
296
|
|
|
277
297
|
for (const hunk of hunks) {
|
|
278
298
|
throwIfAborted(signal);
|
|
279
|
-
const source = await
|
|
280
|
-
if (occupied.has(source.absolute) && !(hunk.kind === "update" && byPath.get(source.absolute)?.kind === "update")) {
|
|
281
|
-
throw new Error(`Patch addresses '${hunk.path}' more than once.`);
|
|
282
|
-
}
|
|
299
|
+
const source = await getVirtualFile(hunk.path);
|
|
283
300
|
|
|
284
301
|
if (hunk.kind === "add") {
|
|
285
|
-
if (source.isDirectory || (source.exists && !source.isFile))
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
302
|
+
if (source.file.isDirectory || (source.file.exists && !source.file.isFile)) {
|
|
303
|
+
throw new Error(`Cannot add file over non-file '${hunk.path}'.`);
|
|
304
|
+
}
|
|
305
|
+
source.file.exists = true;
|
|
306
|
+
source.file.isDirectory = false;
|
|
307
|
+
source.file.isFile = true;
|
|
308
|
+
source.file.content = hunk.content;
|
|
309
|
+
operations.push({
|
|
310
|
+
kind: "add",
|
|
311
|
+
path: source.absolute,
|
|
312
|
+
displayPath: displayPath(root, source.absolute, hunk.path),
|
|
313
|
+
content: hunk.content,
|
|
314
|
+
});
|
|
290
315
|
continue;
|
|
291
316
|
}
|
|
292
317
|
|
|
293
318
|
if (hunk.kind === "delete") {
|
|
294
|
-
if (!source.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
|
|
295
|
-
if (source.isDirectory || !source.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
occupied.add(source.absolute);
|
|
319
|
+
if (!source.file.exists) throw new Error(`Cannot delete missing file '${hunk.path}'.`);
|
|
320
|
+
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot delete non-file '${hunk.path}'.`);
|
|
321
|
+
operations.push({ kind: "delete", path: source.absolute, displayPath: displayPath(root, source.absolute, hunk.path) });
|
|
322
|
+
source.file.exists = false;
|
|
323
|
+
source.file.content = undefined;
|
|
300
324
|
continue;
|
|
301
325
|
}
|
|
302
326
|
|
|
303
|
-
if (!source.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
|
|
304
|
-
if (source.isDirectory || !source.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
|
|
305
|
-
const original = await
|
|
306
|
-
const moveTo = hunk.moveTo ? (
|
|
327
|
+
if (!source.file.exists) throw new Error(`Cannot update missing file '${hunk.path}'.`);
|
|
328
|
+
if (source.file.isDirectory || !source.file.isFile) throw new Error(`Cannot update non-file '${hunk.path}'.`);
|
|
329
|
+
const original = await getVirtualContent(source.absolute, source.file);
|
|
330
|
+
const moveTo = hunk.moveTo ? resolvePatchPath(hunk.moveTo, root) : undefined;
|
|
307
331
|
if (moveTo === source.absolute) throw new Error(`Cannot move '${hunk.path}' onto itself.`);
|
|
332
|
+
|
|
333
|
+
let destination: { absolute: string; file: VirtualFile } | undefined;
|
|
308
334
|
if (moveTo) {
|
|
309
|
-
|
|
310
|
-
if (destination.isDirectory || (destination.exists && !destination.isFile))
|
|
311
|
-
|
|
312
|
-
|
|
335
|
+
destination = await getVirtualFile(hunk.moveTo!);
|
|
336
|
+
if (destination.file.isDirectory || (destination.file.exists && !destination.file.isFile)) {
|
|
337
|
+
throw new Error(`Cannot move file over non-file '${hunk.moveTo}'.`);
|
|
338
|
+
}
|
|
313
339
|
}
|
|
314
340
|
|
|
315
|
-
const
|
|
316
|
-
if (
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
341
|
+
const content = applyUpdateContent(original, hunk.chunks, hunk.path);
|
|
342
|
+
if (destination) {
|
|
343
|
+
operations.push({
|
|
344
|
+
kind: "update",
|
|
345
|
+
path: source.absolute,
|
|
346
|
+
displayPath: displayPath(root, source.absolute, hunk.path),
|
|
347
|
+
moveTo: moveTo!,
|
|
348
|
+
moveDisplayPath: displayPath(root, moveTo!, hunk.moveTo!),
|
|
349
|
+
chunkGroups: [[...hunk.chunks]],
|
|
350
|
+
content,
|
|
351
|
+
});
|
|
352
|
+
source.file.exists = false;
|
|
353
|
+
source.file.content = undefined;
|
|
354
|
+
destination.file.exists = true;
|
|
355
|
+
destination.file.isDirectory = false;
|
|
356
|
+
destination.file.isFile = true;
|
|
357
|
+
destination.file.content = content;
|
|
323
358
|
continue;
|
|
324
359
|
}
|
|
325
360
|
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
for (const operation of operations) {
|
|
341
|
-
if (operation.kind === "update" && operation.moveTo && byPath.has(operation.moveTo)) {
|
|
342
|
-
throw new Error(`Move destination '${operation.moveDisplayPath}' is also changed by this patch.`);
|
|
361
|
+
const previous = operations.at(-1);
|
|
362
|
+
if (previous?.kind === "update" && previous.path === source.absolute && !previous.moveTo) {
|
|
363
|
+
previous.content = content;
|
|
364
|
+
previous.chunkGroups.push([...hunk.chunks]);
|
|
365
|
+
} else {
|
|
366
|
+
operations.push({
|
|
367
|
+
kind: "update",
|
|
368
|
+
path: source.absolute,
|
|
369
|
+
displayPath: displayPath(root, source.absolute, hunk.path),
|
|
370
|
+
chunkGroups: [[...hunk.chunks]],
|
|
371
|
+
content,
|
|
372
|
+
});
|
|
343
373
|
}
|
|
374
|
+
source.file.content = content;
|
|
344
375
|
}
|
|
376
|
+
|
|
345
377
|
return operations;
|
|
346
378
|
}
|
|
347
379
|
|
package/src/install-telemetry.ts
CHANGED
|
@@ -44,14 +44,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
|
|
|
44
44
|
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
function isInstallTelemetryEnabled(): boolean {
|
|
48
|
-
if (isTruthyEnvFlag(
|
|
49
|
-
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(
|
|
50
|
-
if (isTruthyEnvFlag(
|
|
51
|
-
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
47
|
+
export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
|
|
48
|
+
if (isTruthyEnvFlag(env.CI)) return false;
|
|
49
|
+
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
|
|
50
|
+
if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
|
|
52
51
|
|
|
53
|
-
const settings = readJsonFile(
|
|
54
|
-
|
|
52
|
+
const settings = readJsonFile(settingsPath) as SettingsDocument;
|
|
53
|
+
if (settings.enableInstallTelemetry === false) return false;
|
|
54
|
+
if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
|
|
55
|
+
return true;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
58
|
function getPackageVersion(): string {
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Streaming preview rendering for the apply_patch tool.
|
|
2
|
+
//
|
|
3
|
+
// The strict parser in apply-patch.ts rejects incomplete input, but the patch arrives as a
|
|
4
|
+
// growing prefix while the model generates it. This module scans that partial text tolerantly
|
|
5
|
+
// (it never throws) to drive a live, write-style glimpse of the content plus a running
|
|
6
|
+
// added/removed tally. It reuses Pi's shared `renderDiff` primitive for +/- coloring, so the
|
|
7
|
+
// preview stays consistent with the built-in `edit` tool's diff preview.
|
|
8
|
+
import { keyHint, type Theme } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
const FILE_ADD = "*** Add File: ";
|
|
11
|
+
const FILE_DELETE = "*** Delete File: ";
|
|
12
|
+
const FILE_UPDATE = "*** Update File: ";
|
|
13
|
+
const MOVE_TO = "*** Move to: ";
|
|
14
|
+
const END_OF_FILE = "*** End of File";
|
|
15
|
+
const BEGIN_PATCH = "*** Begin Patch";
|
|
16
|
+
const END_PATCH = "*** End Patch";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Preview bounds. The patch itself is capped at apply time (1 MiB / 1000 hunks), but the TUI
|
|
20
|
+
* re-renders on every streaming token, so the preview scanner bails earlier to keep the UI
|
|
21
|
+
* responsive and its retained output bounded (see AGENTS.md: "bound tool output").
|
|
22
|
+
*/
|
|
23
|
+
const PREVIEW_LINES_COLLAPSED = 10;
|
|
24
|
+
const PREVIEW_LINES_EXPANDED = 500;
|
|
25
|
+
const MAX_PREVIEW_BYTES = 256 * 1024;
|
|
26
|
+
const MAX_PREVIEW_FILES = 500;
|
|
27
|
+
/** Cap a single rendered glimpse line so minified/generated content cannot flood the TUI. */
|
|
28
|
+
const PREVIEW_LINE_CHARS = 200;
|
|
29
|
+
|
|
30
|
+
export type PatchLineType = "add" | "del" | "ctx";
|
|
31
|
+
|
|
32
|
+
export interface PatchPreviewLine {
|
|
33
|
+
type: PatchLineType;
|
|
34
|
+
text: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface PatchPreviewFile {
|
|
38
|
+
kind: "add" | "update" | "delete";
|
|
39
|
+
path: string;
|
|
40
|
+
/** Destination for an update that carries `*** Move to:`; execution writes this path and deletes `path`. */
|
|
41
|
+
moveTo?: string;
|
|
42
|
+
/** Diff lines for the file. Empty for deletes (the patch carries no content for them). */
|
|
43
|
+
lines: PatchPreviewLine[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PatchPreview {
|
|
47
|
+
files: PatchPreviewFile[];
|
|
48
|
+
totalAdded: number;
|
|
49
|
+
totalRemoved: number;
|
|
50
|
+
/** True when the preview stopped early because the patch exceeded a preview bound. */
|
|
51
|
+
truncated: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Tolerantly scan a (possibly incomplete) apply_patch body into a preview.
|
|
56
|
+
*
|
|
57
|
+
* The grammar is line-oriented, so a partial buffer is always a prefix of valid lines. We walk
|
|
58
|
+
* every line and accumulate per-file diff lines and running counts; malformed/unknown lines are
|
|
59
|
+
* ignored rather than thrown on. Each call re-scans the current buffer from scratch, so there is
|
|
60
|
+
* no accumulation drift across streaming deltas.
|
|
61
|
+
*/
|
|
62
|
+
export function scanPatchPreview(input: string): PatchPreview {
|
|
63
|
+
// Bound per-render work: cap the bytes we split and walk so a large patch (up to 1 MiB at apply
|
|
64
|
+
// time) cannot make streaming re-renders quadratic. Anything past the cap is reported truncated.
|
|
65
|
+
const capped = input.length > MAX_PREVIEW_BYTES + 1 ? input.slice(0, MAX_PREVIEW_BYTES + 1) : input;
|
|
66
|
+
const lines = capped.split("\n");
|
|
67
|
+
|
|
68
|
+
const filesByPath = new Map<string, PatchPreviewFile>();
|
|
69
|
+
const files: PatchPreviewFile[] = []; // first-seen order
|
|
70
|
+
let totalAdded = 0;
|
|
71
|
+
let totalRemoved = 0;
|
|
72
|
+
let current: PatchPreviewFile | undefined;
|
|
73
|
+
let truncated = input.length > capped.length;
|
|
74
|
+
|
|
75
|
+
for (const raw of lines) {
|
|
76
|
+
const line = raw.replace(/\r$/, "");
|
|
77
|
+
// The terminator ends the patch; trailing text must not change totals or the active file.
|
|
78
|
+
if (line === END_PATCH) break;
|
|
79
|
+
if (line === "" || line === BEGIN_PATCH || line === END_OF_FILE) continue;
|
|
80
|
+
|
|
81
|
+
if (line.startsWith(MOVE_TO)) {
|
|
82
|
+
// A move renames the current update's file; preserve the destination so the preview can
|
|
83
|
+
// show the source -> destination transition (execution deletes source, writes destination).
|
|
84
|
+
if (current) current.moveTo = line.slice(MOVE_TO.length).trim();
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (line.startsWith(FILE_ADD) || line.startsWith(FILE_DELETE) || line.startsWith(FILE_UPDATE)) {
|
|
89
|
+
const kind: PatchPreviewFile["kind"] = line.startsWith(FILE_ADD)
|
|
90
|
+
? "add"
|
|
91
|
+
: line.startsWith(FILE_DELETE)
|
|
92
|
+
? "delete"
|
|
93
|
+
: "update";
|
|
94
|
+
const prefix = kind === "add" ? FILE_ADD : kind === "delete" ? FILE_DELETE : FILE_UPDATE;
|
|
95
|
+
const path = line.slice(prefix.length);
|
|
96
|
+
const existing = filesByPath.get(path);
|
|
97
|
+
if (existing) {
|
|
98
|
+
// Execution coalesces multiple hunks for the same path; the preview must too, otherwise
|
|
99
|
+
// the roster shows duplicate rows and an inflated file count.
|
|
100
|
+
current = existing;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (files.length >= MAX_PREVIEW_FILES) {
|
|
104
|
+
truncated = true;
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
current = { kind, path, lines: [] };
|
|
108
|
+
filesByPath.set(path, current);
|
|
109
|
+
files.push(current);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Lines before the first file header (or inside a delete) carry no preview content.
|
|
114
|
+
if (!current || current.kind === "delete") continue;
|
|
115
|
+
|
|
116
|
+
if (line === "@@" || line.startsWith("@@ ")) continue; // chunk context marker
|
|
117
|
+
|
|
118
|
+
if (line.startsWith("+")) {
|
|
119
|
+
current.lines.push({ type: "add", text: line.slice(1) });
|
|
120
|
+
totalAdded++;
|
|
121
|
+
} else if (line.startsWith("-")) {
|
|
122
|
+
current.lines.push({ type: "del", text: line.slice(1) });
|
|
123
|
+
totalRemoved++;
|
|
124
|
+
} else {
|
|
125
|
+
// Context line (" text") or a bare "" inside a chunk.
|
|
126
|
+
current.lines.push({ type: "ctx", text: line.startsWith(" ") ? line.slice(1) : line });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return { files, totalAdded, totalRemoved, truncated };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fileStatusMark(file: PatchPreviewFile): string {
|
|
134
|
+
switch (file.kind) {
|
|
135
|
+
case "add":
|
|
136
|
+
return "A";
|
|
137
|
+
case "delete":
|
|
138
|
+
return "D";
|
|
139
|
+
default:
|
|
140
|
+
return "M";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function fileCountsLabel(file: PatchPreviewFile, theme: Theme): string {
|
|
145
|
+
if (file.kind === "delete") return "";
|
|
146
|
+
const added = theme.fg("toolDiffAdded", `+${countLines(file, "add")}`);
|
|
147
|
+
const removed = file.kind === "update" ? ` ${theme.fg("toolDiffRemoved", `-${countLines(file, "del")}`)}` : "";
|
|
148
|
+
return `${added}${removed}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function fileMarkLabel(file: PatchPreviewFile, theme: Theme): string {
|
|
152
|
+
const mark = file.kind === "add" ? "toolDiffAdded" : file.kind === "delete" ? "toolDiffRemoved" : "warning";
|
|
153
|
+
return theme.fg(mark, fileStatusMark(file));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function filePathLabel(file: PatchPreviewFile, theme: Theme): string {
|
|
157
|
+
const path = theme.fg("accent", truncatePath(file.path));
|
|
158
|
+
return file.moveTo ? `${path} ${theme.fg("muted", "->")} ${theme.fg("accent", truncatePath(file.moveTo))}` : path;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function formatFileRosterLine(file: PatchPreviewFile, theme: Theme): string {
|
|
162
|
+
const counts = fileCountsLabel(file, theme);
|
|
163
|
+
return `${fileMarkLabel(file, theme)} ${filePathLabel(file, theme)}${counts ? ` ${counts}` : ""}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function countLines(file: PatchPreviewFile, type: PatchLineType): number {
|
|
167
|
+
let n = 0;
|
|
168
|
+
for (const line of file.lines) if (line.type === type) n++;
|
|
169
|
+
return n;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Render one glimpse line directly. apply_patch has no real line numbers (only `@@` anchors), so
|
|
174
|
+
* we color +/- lines without fabricating numbers, and cap each line's width.
|
|
175
|
+
*/
|
|
176
|
+
function renderGlimpseLine(line: PatchPreviewLine, theme: Theme): string {
|
|
177
|
+
const sign = line.type === "add" ? "+" : line.type === "del" ? "-" : " ";
|
|
178
|
+
const body = line.text.length > PREVIEW_LINE_CHARS ? `${line.text.slice(0, PREVIEW_LINE_CHARS)}…` : line.text;
|
|
179
|
+
const styled = `${sign}${body}`;
|
|
180
|
+
if (line.type === "add") return theme.fg("toolDiffAdded", styled);
|
|
181
|
+
if (line.type === "del") return theme.fg("toolDiffRemoved", styled);
|
|
182
|
+
return theme.fg("toolDiffContext", styled);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function focusFile(files: PatchPreviewFile[]): PatchPreviewFile | undefined {
|
|
186
|
+
// Prefer the most recent file that has visible content to glimpse; fall back to the last file.
|
|
187
|
+
for (let index = files.length - 1; index >= 0; index--) {
|
|
188
|
+
const file = files[index];
|
|
189
|
+
if (file.kind !== "delete" && file.lines.length > 0) return file;
|
|
190
|
+
}
|
|
191
|
+
return files.at(-1);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Cap a rendered path: keep the tail (filename/extension) since that is the meaningful part. */
|
|
195
|
+
function truncatePath(path: string): string {
|
|
196
|
+
return path.length > PREVIEW_LINE_CHARS ? `…${path.slice(path.length - PREVIEW_LINE_CHARS + 1)}` : path;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Format the live apply_patch tool-call text (streaming glimpse + tally). */
|
|
200
|
+
export function formatApplyPatchCallText(rawPatch: string, theme: Theme, options: { expanded: boolean }): string {
|
|
201
|
+
const preview = scanPatchPreview(rawPatch);
|
|
202
|
+
const title = theme.fg("toolTitle", theme.bold("apply_patch"));
|
|
203
|
+
if (preview.files.length === 0) return title;
|
|
204
|
+
|
|
205
|
+
let text: string;
|
|
206
|
+
if (preview.files.length === 1) {
|
|
207
|
+
// Single file: lead with the status mark + path (like the built-in `write` tool, with the
|
|
208
|
+
// file's A/M/D status so a delete is distinguishable from an empty in-progress update).
|
|
209
|
+
const file = preview.files[0];
|
|
210
|
+
const counts = fileCountsLabel(file, theme);
|
|
211
|
+
text = `${title} ${fileMarkLabel(file, theme)} ${filePathLabel(file, theme)}${counts ? ` ${counts}` : ""}`;
|
|
212
|
+
} else {
|
|
213
|
+
const tally = `${theme.fg("toolDiffAdded", `+${preview.totalAdded}`)} ${theme.fg("toolDiffRemoved", `-${preview.totalRemoved}`)} ${theme.fg("muted", `· ${preview.files.length} files`)}`;
|
|
214
|
+
// Cap the collapsed roster so a multi-hundred-file patch cannot re-render hundreds of lines
|
|
215
|
+
// every token; expansion reveals the rest (up to the preview file cap).
|
|
216
|
+
const maxRoster = options.expanded ? preview.files.length : PREVIEW_LINES_COLLAPSED;
|
|
217
|
+
const visible = preview.files.slice(0, maxRoster);
|
|
218
|
+
let roster = visible.map((file) => formatFileRosterLine(file, theme)).join("\n");
|
|
219
|
+
const hidden = preview.files.length - visible.length;
|
|
220
|
+
if (hidden > 0) roster += `\n${theme.fg("muted", `… ${hidden} more files`)}`;
|
|
221
|
+
text = `${title} ${tally}\n${roster}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const focus = focusFile(preview.files);
|
|
225
|
+
if (focus && focus.kind !== "delete" && focus.lines.length > 0) {
|
|
226
|
+
// Even expanded, cap rendered output so a huge file cannot stall the terminal.
|
|
227
|
+
const maxLines = options.expanded ? PREVIEW_LINES_EXPANDED : PREVIEW_LINES_COLLAPSED;
|
|
228
|
+
const visible = focus.lines.slice(0, maxLines);
|
|
229
|
+
const remaining = focus.lines.length - maxLines;
|
|
230
|
+
let body = visible.map((line) => renderGlimpseLine(line, theme)).join("\n");
|
|
231
|
+
if (remaining > 0) {
|
|
232
|
+
body += theme.fg(
|
|
233
|
+
"muted",
|
|
234
|
+
`\n... (${remaining} more lines, ${focus.lines.length} total, ${keyHint("app.tools.expand", "to expand")})`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
// In multi-file previews the focused file may be hidden behind the capped roster, so label the
|
|
238
|
+
// glimpse with its own row (the single-file header already carries the path).
|
|
239
|
+
const label = preview.files.length > 1 ? `${formatFileRosterLine(focus, theme)}\n` : "";
|
|
240
|
+
text += `\n\n${label}${body}`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
if (preview.truncated) {
|
|
244
|
+
text += `\n${theme.fg("muted", "(large patch; preview truncated)")}`;
|
|
245
|
+
}
|
|
246
|
+
return text;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Format the apply_patch tool-result text. On success the streaming glimpse already conveys the
|
|
251
|
+
* change, so nothing is added (mirrors the built-in `write` tool). On error the message is shown.
|
|
252
|
+
*/
|
|
253
|
+
export function formatApplyPatchResultText(
|
|
254
|
+
result: { content: Array<{ type: string; text?: string }> },
|
|
255
|
+
theme: Theme,
|
|
256
|
+
isError: boolean,
|
|
257
|
+
): string | undefined {
|
|
258
|
+
if (!isError) return undefined;
|
|
259
|
+
const output = result.content
|
|
260
|
+
.filter((part) => part.type === "text")
|
|
261
|
+
.map((part) => part.text ?? "")
|
|
262
|
+
.join("\n")
|
|
263
|
+
.trim();
|
|
264
|
+
return output ? theme.fg("error", output) : undefined;
|
|
265
|
+
}
|