pi-shorthand 0.3.0 → 0.3.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/README.md +15 -75
- package/display.ts +6 -7
- package/file-outcomes.ts +100 -0
- package/index.ts +39 -39
- package/overlay-linux.ts +0 -7
- package/overlay-macos.ts +14 -4
- package/package.json +2 -2
- package/placement.ts +21 -0
- package/prelude.ts +47 -22
- package/runner.ts +168 -89
- package/skills/shorthand/advanced-refactors.md +11 -5
- package/history.ts +0 -252
package/README.md
CHANGED
|
@@ -1,93 +1,33 @@
|
|
|
1
1
|
# pi-shorthand
|
|
2
2
|
|
|
3
|
-

|
|
4
4
|
|
|
5
5
|
A [Pi](https://github.com/earendil-works/pi) tool for editing repositories with Bun programs.
|
|
6
|
-
The model can combine ordinary JavaScript, text edits and structural transformations in one call.
|
|
7
|
-
|
|
8
|
-
The program sees your repo as normal, but its writes are held back. By default, they're applied only
|
|
9
|
-
if the program succeeds and destination files are unchanged, and the model gets the diff.
|
|
10
|
-
The program performs the edit; tests, type-checks and other verification run separately afterward.
|
|
11
6
|
|
|
12
7
|
## Install
|
|
13
8
|
|
|
9
|
+
Install pi-shorthand and initialize Grit:
|
|
10
|
+
|
|
14
11
|
```sh
|
|
15
12
|
pi install npm:pi-shorthand
|
|
13
|
+
npx @getgrit/cli init --global
|
|
16
14
|
```
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
A project install (`pi install -l`) only loads once you trust the project: Pi asks, or run `pi --approve`.
|
|
21
|
-
|
|
22
|
-
You also need Bun, git, and either [bubblewrap](https://github.com/containers/bubblewrap) 0.9+
|
|
23
|
-
(Linux) or [AgentFS](https://github.com/tursodatabase/agentfs) and `clang` (macOS:
|
|
24
|
-
`curl -fsSL https://agentfs.ai/install | bash`).
|
|
16
|
+
On macOS, install clang and AgentFS:
|
|
25
17
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
```ts
|
|
31
|
-
await $`git ls-files`.text(); // Bun's shell (the only async one)
|
|
32
|
-
glob("src/**/*.ts"); // → ["src/a.ts", …]
|
|
33
|
-
grep("oldApi(", "src"); // → [{ file, line, text }, …]
|
|
34
|
-
sg.find("oldApi($$$ARGS)", "src"); // ast-grep search
|
|
35
|
-
sg.rewrite("oldApi($$$ARGS)", "newApi($$$ARGS)", "src");
|
|
36
|
-
sg.insert("initialize();", { before: sg.one("run();", "src/app.ts") });
|
|
37
|
-
sg.move(sg.one("function helper() { $$$BODY }", "src/old.ts"), { endOf: sg.file("src/new.ts") });
|
|
38
|
-
sg.remove(sg.one("obsolete();", "src/app.ts"));
|
|
39
|
-
sg.parse(sg.Lang.TypeScript, source); // ast-grep's own API (or import from "@ast-grep/napi")
|
|
40
|
-
grit("`console.log($x)` => `logger.info($x)`", "src");
|
|
18
|
+
```sh
|
|
19
|
+
xcode-select --install
|
|
20
|
+
curl -fsSL https://agentfs.ai/install | bash
|
|
41
21
|
```
|
|
42
22
|
|
|
43
|
-
|
|
44
|
-
in arrays mixed with paths. Search reads the file's current contents. Missing targets are valid insertion
|
|
45
|
-
destinations but cannot be searched. An explicit target can select an ignored file inside the workspace;
|
|
46
|
-
the tool still only applies Git-visible changes.
|
|
23
|
+
On Linux, install bubblewrap 0.9 or later. For Debian and Ubuntu:
|
|
47
24
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
globals. Bun execution does not automatically type-check programs or run application verification.
|
|
52
|
-
|
|
53
|
-
## Options
|
|
54
|
-
|
|
55
|
-
- `title`: a short description shown with the call (required).
|
|
56
|
-
- `rollback`: `"all"` (default) applies nothing if the program fails. After a timeout, `"file"`
|
|
57
|
-
keeps changed files that were no longer open for writing if writer inspection succeeds. Other
|
|
58
|
-
failures apply nothing because open writers cannot be identified after the process exits.
|
|
59
|
-
- `timeout`: seconds before the program is killed. Default 2.
|
|
60
|
-
|
|
61
|
-
## Good to know
|
|
62
|
-
|
|
63
|
-
- Only files git tracks, or would track, are applied.
|
|
64
|
-
- Successful edits are formatted with detected installed project tools (Prettier, oxfmt, Biome, Ruff,
|
|
65
|
-
Black, gofmt or rustfmt) before the final diff. Ambiguous setups are skipped; formatter failures warn
|
|
66
|
-
without discarding completed edits. Set `PI_SHORTHAND_FORMAT=0` to disable. No project config is required.
|
|
67
|
-
- Each run snapshots the checkout first; reflinks make that cheap where supported, while other
|
|
68
|
-
filesystems copy its contents and use corresponding temporary space. On macOS the program runs at
|
|
69
|
-
a private AgentFS mount, so use paths relative to its working directory for repository files.
|
|
70
|
-
- Runs against the same checkout are serialized. If another process edits a destination while a run
|
|
71
|
-
is in progress, shorthand checks it again immediately before replacing it and reports a conflict.
|
|
72
|
-
A non-cooperating writer can still race the final filesystem rename or removal itself.
|
|
73
|
-
- To try it with only `read` and `code`: `pi --tools read,code`.
|
|
74
|
-
- Run history is stored in `~/.cache/pi-shorthand/runs.jsonl` with directory mode `0700` and file
|
|
75
|
-
mode `0600`. It records timestamps, opaque run IDs, lifecycle events, exit status, durations,
|
|
76
|
-
counts, helper names, and shell executable names. It does not record programs, output, errors,
|
|
77
|
-
arguments, repository paths, file paths, or diffs. The log rotates at 1 MiB and expires after
|
|
78
|
-
seven days. Set `PI_SHORTHAND_HISTORY=0` to disable it. To watch enabled history:
|
|
79
|
-
`tail -f ~/.cache/pi-shorthand/runs.jsonl`.
|
|
80
|
-
|
|
81
|
-
## Developing
|
|
25
|
+
```sh
|
|
26
|
+
sudo apt install bubblewrap
|
|
27
|
+
```
|
|
82
28
|
|
|
83
|
-
|
|
84
|
-
hook runs it; `npm run format` fixes formatting.
|
|
29
|
+
## Technical choices
|
|
85
30
|
|
|
86
|
-
|
|
87
|
-
`test/linux.sh` runs them on Linux in Docker.
|
|
31
|
+
pi-shorthand gives Pi a programming environment instead of a patch format. This makes multi-file and structural edits possible in one call, but does not guarantee that Pi will find it easier or more reliable than its built-in edit tool. Which works better depends on the model and the task.
|
|
88
32
|
|
|
89
|
-
|
|
90
|
-
The [comparison harness](e2e/README.md) supports stock, optional and replacement editing tools, controlled
|
|
91
|
-
documentation/skills, independent task checks, saved final changes and session reports.
|
|
92
|
-
`bun e2e/run.ts --repo <path or git URL> --task "…" --setups baseline,replace,code --check "…"` runs Pi
|
|
93
|
-
with a real model on fresh copies of your own repository.
|
|
33
|
+
Programs edit an isolated snapshot, with host files outside the repository kept read-only. By default, a failure keeps completed files and rolls back files involved in failed or interrupted edits; changes are applied only if their destination files have not changed. Progress is reported while a program runs, but no run history is written to disk.
|
package/display.ts
CHANGED
|
@@ -21,7 +21,7 @@ const LISTED_FILES = 8; // …showing this many, then "and N more files"
|
|
|
21
21
|
const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
|
|
22
22
|
|
|
23
23
|
export function callLine(args: { title?: string; timeout?: number; rollback?: string }, theme: Theme): string {
|
|
24
|
-
const settings = [args.rollback === "
|
|
24
|
+
const settings = [args.rollback === "all" && "rollback all", args.timeout && `timeout ${args.timeout}s`];
|
|
25
25
|
const suffix = settings.filter(Boolean).join(", ");
|
|
26
26
|
return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
|
|
27
27
|
}
|
|
@@ -69,17 +69,16 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
|
|
|
69
69
|
}
|
|
70
70
|
for (const file of run.rolledBack) {
|
|
71
71
|
const reason = run.writerInspectionFailed
|
|
72
|
-
? "open writers could not be inspected
|
|
73
|
-
:
|
|
74
|
-
? "half-written when the program was killed"
|
|
75
|
-
: "finished writes unknown after the program exited";
|
|
72
|
+
? "open writers could not be inspected"
|
|
73
|
+
: "file edit failed or was interrupted";
|
|
76
74
|
lines.push(indent(theme.fg("warning", `rolled back ${file}: ${reason}`)));
|
|
77
75
|
}
|
|
78
76
|
for (const warning of [...run.warnings, ...printedWarnings]) lines.push(indent(theme.fg("warning", `⚠ ${warning}`)));
|
|
79
77
|
|
|
80
78
|
// The sections, each after a blank line.
|
|
81
79
|
const sections: string[][] = [];
|
|
82
|
-
const background: ToolBackground =
|
|
80
|
+
const background: ToolBackground =
|
|
81
|
+
run.exitCode === 0 && run.conflicts.length === 0 && run.rolledBack.length === 0 ? "toolSuccessBg" : "toolErrorBg";
|
|
83
82
|
if (applied.length > 0) sections.push(diffLines(applied, expanded, theme, background));
|
|
84
83
|
if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
|
|
85
84
|
if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
|
|
@@ -104,7 +103,7 @@ function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
|
|
|
104
103
|
return theme.fg("error", `✕ Conflict · ${application}${program}`) + took;
|
|
105
104
|
}
|
|
106
105
|
if (run.exitCode === 0 && run.changes.length === 0) return theme.fg("success", "✓ No changes") + took;
|
|
107
|
-
if (run.exitCode === 0) {
|
|
106
|
+
if (run.exitCode === 0 && run.rolledBack.length === 0) {
|
|
108
107
|
return theme.fg("success", `✓ Applied ${fileCount(applied)}`) + ` · ${stats(applied, theme)}` + took;
|
|
109
108
|
}
|
|
110
109
|
if (applied.length > 0) {
|
package/file-outcomes.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Per-file edit outcomes sent to the runner over a private inherited descriptor. */
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { realpathSync, writeSync } from "node:fs";
|
|
4
|
+
import { isAbsolute, relative, resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
export type FileOutcomeEvent =
|
|
7
|
+
| { type: "begin"; id: number; files: string[] }
|
|
8
|
+
| { type: "end" | "fail"; id: number }
|
|
9
|
+
| { type: "error"; files: string[] }
|
|
10
|
+
| { type: "writers"; files: string[] | null };
|
|
11
|
+
|
|
12
|
+
const descriptor = process.env.PI_SHORTHAND_OUTCOMES_FD;
|
|
13
|
+
const root = process.env.PI_SHORTHAND_EXECUTION_ROOT;
|
|
14
|
+
const forceInspectionFailure = process.env.PI_SHORTHAND_INSPECTION_FAILURE === "1";
|
|
15
|
+
// Subprocesses do not inherit descriptor 3 by default, so do not advertise it to them.
|
|
16
|
+
delete process.env.PI_SHORTHAND_OUTCOMES_FD;
|
|
17
|
+
delete process.env.PI_SHORTHAND_EXECUTION_ROOT;
|
|
18
|
+
delete process.env.PI_SHORTHAND_INSPECTION_FAILURE;
|
|
19
|
+
let nextId = 0;
|
|
20
|
+
|
|
21
|
+
function send(event: FileOutcomeEvent): void {
|
|
22
|
+
if (!descriptor) return;
|
|
23
|
+
const bytes = Buffer.from(JSON.stringify(event) + "\n");
|
|
24
|
+
let offset = 0;
|
|
25
|
+
while (offset < bytes.length) offset += writeSync(Number(descriptor), bytes, offset);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function paths(files: readonly string[]): string[] {
|
|
29
|
+
if (!root) return [];
|
|
30
|
+
return [
|
|
31
|
+
...new Set(
|
|
32
|
+
files
|
|
33
|
+
.filter((file) => typeof file === "string")
|
|
34
|
+
.flatMap((file) => {
|
|
35
|
+
const absolute = resolve(file);
|
|
36
|
+
try {
|
|
37
|
+
return [relative(root, absolute), relative(root, realpathSync(absolute))];
|
|
38
|
+
} catch {
|
|
39
|
+
return [relative(root, absolute)];
|
|
40
|
+
}
|
|
41
|
+
}),
|
|
42
|
+
),
|
|
43
|
+
].filter((file) => file !== ".." && !file.startsWith("../") && !isAbsolute(file));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** An operation may touch several files (for example, moving a node between files). */
|
|
47
|
+
export function editingFiles<T>(files: readonly string[], edit: () => T): T {
|
|
48
|
+
if (!descriptor) return edit();
|
|
49
|
+
const id = nextId++;
|
|
50
|
+
send({ type: "begin", id, files: paths(files) });
|
|
51
|
+
try {
|
|
52
|
+
const result = edit();
|
|
53
|
+
send({ type: "end", id });
|
|
54
|
+
return result;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
send({ type: "fail", id });
|
|
57
|
+
throw error;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function installFileOutcomeTracking(): void {
|
|
62
|
+
if (!descriptor) return;
|
|
63
|
+
process.on("uncaughtExceptionMonitor", (error) => {
|
|
64
|
+
// Native filesystem errors identify their paths even when no editing helper was involved.
|
|
65
|
+
const details = (typeof error === "object" && error !== null ? error : {}) as { path?: unknown; dest?: unknown };
|
|
66
|
+
const files = [details.path, details.dest].filter((file): file is string => typeof file === "string");
|
|
67
|
+
send({ type: "error", files: paths(files) });
|
|
68
|
+
});
|
|
69
|
+
process.on("exit", (code) => {
|
|
70
|
+
if (code === 0) return;
|
|
71
|
+
send({ type: "writers", files: inspectWriters() });
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Exit handlers run before descriptors close, so ordinary errors can be inspected too. */
|
|
76
|
+
function inspectWriters(): string[] | null {
|
|
77
|
+
if (!root || forceInspectionFailure) return null;
|
|
78
|
+
// bubblewrap replaces /dev, hiding the host's message-queue mount. Exempt that unrelated
|
|
79
|
+
// mount from stat probes; otherwise lsof reports incomplete output for every Linux run.
|
|
80
|
+
const args = ["-n", "-P", "-F", "an", ...(process.platform === "linux" ? ["-e", "/dev/mqueue"] : [])];
|
|
81
|
+
const result = spawnSync("lsof", args, {
|
|
82
|
+
encoding: "utf8",
|
|
83
|
+
timeout: 2000,
|
|
84
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
85
|
+
});
|
|
86
|
+
if (result.error || result.status !== 0 || result.stderr) return null;
|
|
87
|
+
return parseOpenWriters(result.stdout, root);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function parseOpenWriters(output: string, directory: string): string[] {
|
|
91
|
+
const files = new Set<string>();
|
|
92
|
+
let access = "";
|
|
93
|
+
for (const line of output.split("\n")) {
|
|
94
|
+
if (line.startsWith("f")) access = "";
|
|
95
|
+
if (line.startsWith("a")) access = line.slice(1);
|
|
96
|
+
if (line.startsWith(`n${directory}/`) && (access === "w" || access === "u"))
|
|
97
|
+
files.add(relative(directory, line.slice(1)));
|
|
98
|
+
}
|
|
99
|
+
return [...files];
|
|
100
|
+
}
|
package/index.ts
CHANGED
|
@@ -4,15 +4,15 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { spawn } from "node:child_process";
|
|
7
|
-
import {
|
|
7
|
+
import { writeFileSync } from "node:fs";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import * as path from "node:path";
|
|
10
|
+
import type { Readable } from "node:stream";
|
|
10
11
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
11
12
|
import { type ExtensionAPI, type Theme, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
|
|
12
13
|
import { Text } from "@earendil-works/pi-tui";
|
|
13
14
|
import { Type } from "typebox";
|
|
14
15
|
import { callLine, countLines, fileMetadataSummary, resultLines, unstructuredResultText } from "./display.ts";
|
|
15
|
-
import { RUN_HISTORY_FILE } from "./history.ts";
|
|
16
16
|
import type { FileChange, RunOptions, RunResult } from "./runner.ts";
|
|
17
17
|
|
|
18
18
|
// Runs typically take well under a second. Longer transformations can request more time.
|
|
@@ -34,14 +34,19 @@ export default function (pi: ExtensionAPI) {
|
|
|
34
34
|
// rather than throwing, since a thrown error loses them.)
|
|
35
35
|
pi.on("tool_result", async (event) => {
|
|
36
36
|
const run = event.details as RunResult | undefined;
|
|
37
|
-
if (
|
|
37
|
+
if (
|
|
38
|
+
event.toolName === "code" &&
|
|
39
|
+
run &&
|
|
40
|
+
(run.exitCode !== 0 || run.conflicts.length > 0 || run.rolledBack.length > 0)
|
|
41
|
+
)
|
|
42
|
+
return { isError: true };
|
|
38
43
|
});
|
|
39
44
|
|
|
40
45
|
pi.registerTool({
|
|
41
46
|
name: "code",
|
|
42
47
|
label: "Code",
|
|
43
48
|
description: DESCRIPTION,
|
|
44
|
-
promptSnippet: "Make a change with one
|
|
49
|
+
promptSnippet: "Make a change with one Bun editing program; run verification separately afterward",
|
|
45
50
|
|
|
46
51
|
parameters: Type.Object({
|
|
47
52
|
title: Type.String({ description: "A few words describing the change, shown to the user" }),
|
|
@@ -49,19 +54,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
49
54
|
rollback: Type.Optional(
|
|
50
55
|
StringEnum(["all", "file"] as const, {
|
|
51
56
|
description:
|
|
52
|
-
'On failure: "
|
|
57
|
+
'On failure: "file" (default) rolls back failed or interrupted file edits and retains the others; "all" applies nothing',
|
|
53
58
|
}),
|
|
54
59
|
),
|
|
55
60
|
timeout: Type.Optional(Type.Number({ description: "Seconds before the program is killed (default 2)" })),
|
|
56
61
|
}),
|
|
57
62
|
|
|
58
63
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
59
|
-
// While it runs, show how long it's been going and the latest step
|
|
60
|
-
const runId = Math.random().toString(36).slice(2, 10);
|
|
64
|
+
// While it runs, show how long it's been going and the latest reported step.
|
|
61
65
|
const startedAt = Date.now();
|
|
66
|
+
let latest: string | undefined;
|
|
62
67
|
const progress = setInterval(() => {
|
|
63
68
|
const elapsed = `${((Date.now() - startedAt) / 1000).toFixed(1)} s`;
|
|
64
|
-
const latest = latestStep(runId);
|
|
65
69
|
onUpdate?.({
|
|
66
70
|
content: [{ type: "text", text: "running" }],
|
|
67
71
|
details: { progress: latest ? `${elapsed} · ${latest}` : elapsed },
|
|
@@ -70,13 +74,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
70
74
|
|
|
71
75
|
const result = await runWithBun(
|
|
72
76
|
{
|
|
73
|
-
runId,
|
|
74
77
|
cwd: ctx.cwd,
|
|
75
78
|
program: params.program,
|
|
76
79
|
timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
77
|
-
rollback: params.rollback ?? "
|
|
80
|
+
rollback: params.rollback ?? "file",
|
|
78
81
|
},
|
|
79
82
|
signal,
|
|
83
|
+
(step) => (latest = step),
|
|
80
84
|
).finally(() => clearInterval(progress));
|
|
81
85
|
return {
|
|
82
86
|
content: [{ type: "text", text: textForModel(result, toolCallId) }],
|
|
@@ -113,10 +117,17 @@ export function renderCodeResult(
|
|
|
113
117
|
* to stop (it then puts the repository back and applies nothing). It has its own process group,
|
|
114
118
|
* which is killed afterwards so no subprocess the program started is left behind.
|
|
115
119
|
*/
|
|
116
|
-
export function runWithBun(
|
|
120
|
+
export function runWithBun(
|
|
121
|
+
options: RunOptions,
|
|
122
|
+
signal?: AbortSignal,
|
|
123
|
+
onProgress?: (step: string) => void,
|
|
124
|
+
): Promise<RunResult> {
|
|
117
125
|
return new Promise((resolve, reject) => {
|
|
118
126
|
if (signal?.aborted) return reject(new Error("Aborted"));
|
|
119
|
-
const runner = spawn("bun", [path.join(import.meta.dirname, "runner.ts")], {
|
|
127
|
+
const runner = spawn("bun", [path.join(import.meta.dirname, "runner.ts")], {
|
|
128
|
+
detached: true,
|
|
129
|
+
stdio: ["pipe", "pipe", "pipe", "pipe"],
|
|
130
|
+
});
|
|
120
131
|
const stop = () => runner.kill("SIGTERM");
|
|
121
132
|
signal?.addEventListener("abort", stop);
|
|
122
133
|
|
|
@@ -126,6 +137,22 @@ export function runWithBun(options: RunOptions, signal?: AbortSignal): Promise<R
|
|
|
126
137
|
runner.stderr.setEncoding("utf8");
|
|
127
138
|
runner.stdout.on("data", (chunk) => (stdout += chunk));
|
|
128
139
|
runner.stderr.on("data", (chunk) => (stderr += chunk));
|
|
140
|
+
let progressBuffer = "";
|
|
141
|
+
const progress = runner.stdio[3] as Readable;
|
|
142
|
+
progress.setEncoding("utf8");
|
|
143
|
+
progress.on("data", (chunk) => {
|
|
144
|
+
progressBuffer += chunk;
|
|
145
|
+
const lines = progressBuffer.split("\n");
|
|
146
|
+
progressBuffer = lines.pop() ?? "";
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
try {
|
|
149
|
+
const event = JSON.parse(line) as { step?: unknown };
|
|
150
|
+
if (typeof event.step === "string") onProgress?.(event.step);
|
|
151
|
+
} catch {
|
|
152
|
+
// Progress is advisory; malformed events do not affect the run.
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
129
156
|
runner.on("error", reject);
|
|
130
157
|
runner.on("close", (code) => {
|
|
131
158
|
signal?.removeEventListener("abort", stop);
|
|
@@ -145,33 +172,6 @@ export function runWithBun(options: RunOptions, signal?: AbortSignal): Promise<R
|
|
|
145
172
|
});
|
|
146
173
|
}
|
|
147
174
|
|
|
148
|
-
/**
|
|
149
|
-
* The latest step logged for a run, e.g. "$ find / -name x" or "grep (18 ms)". Reads only the end of
|
|
150
|
-
* the log, which is shared by every run.
|
|
151
|
-
*/
|
|
152
|
-
function latestStep(runId: string): string | undefined {
|
|
153
|
-
let tail: string;
|
|
154
|
-
try {
|
|
155
|
-
const size = statSync(RUN_HISTORY_FILE).size;
|
|
156
|
-
const length = Math.min(size, 64 * 1024);
|
|
157
|
-
const buffer = Buffer.alloc(length);
|
|
158
|
-
const file = openSync(RUN_HISTORY_FILE, "r");
|
|
159
|
-
readSync(file, buffer, 0, length, size - length);
|
|
160
|
-
closeSync(file);
|
|
161
|
-
tail = buffer.toString("utf8");
|
|
162
|
-
} catch {
|
|
163
|
-
return undefined; // no log yet
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const lines = tail.split("\n").filter((line) => line.includes(`"run":"${runId}"`));
|
|
167
|
-
const last = lines.at(-1);
|
|
168
|
-
if (!last) return undefined;
|
|
169
|
-
const event = JSON.parse(last);
|
|
170
|
-
if (event.event === "command") return `$ ${event.command}`;
|
|
171
|
-
if (event.event === "helper") return `${event.helper} (${event.ms} ms)`;
|
|
172
|
-
return event.event;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
175
|
/** e.g. "✓ exit 0 · 326 ms · 6 files · +48 −17 · applied" */
|
|
176
176
|
function summaryLine(run: RunResult): string {
|
|
177
177
|
const parts = [];
|
package/overlay-linux.ts
CHANGED
|
@@ -9,7 +9,6 @@ import * as fs from "node:fs/promises";
|
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import * as path from "node:path";
|
|
11
11
|
import { $ } from "bun";
|
|
12
|
-
import { historyEnabled, RUN_HISTORY_FILE, RUN_HISTORY_LOCK_DIR } from "./history.ts";
|
|
13
12
|
import type { FilesystemEntry, Overlay } from "./runner.ts";
|
|
14
13
|
|
|
15
14
|
export async function openLinuxOverlay(repo: string, tempDir: string): Promise<Overlay> {
|
|
@@ -24,8 +23,6 @@ export async function openLinuxOverlay(repo: string, tempDir: string): Promise<O
|
|
|
24
23
|
const cacheDir = path.join(homedir(), ".cache", "pi-shorthand");
|
|
25
24
|
const internalDir = path.join(cacheDir, "sandbox");
|
|
26
25
|
const sandboxExcludesFile = path.join(internalDir, `${path.basename(tempDir)}.exclude`);
|
|
27
|
-
const logFile = RUN_HISTORY_FILE;
|
|
28
|
-
const keepHistory = historyEnabled();
|
|
29
26
|
await copyStableTree(repo, lower);
|
|
30
27
|
const filesAtStart = await gitVisibleFiles(lower);
|
|
31
28
|
await fs.mkdir(upper);
|
|
@@ -41,8 +38,6 @@ export async function openLinuxOverlay(repo: string, tempDir: string): Promise<O
|
|
|
41
38
|
}
|
|
42
39
|
if ((internalStats.mode & 0o077) !== 0) await fs.chmod(internalDir, 0o700);
|
|
43
40
|
await fs.writeFile(sandboxExcludesFile, "", { flag: "wx", mode: 0o600 });
|
|
44
|
-
if (keepHistory) await fs.appendFile(logFile, "", { mode: 0o600 });
|
|
45
|
-
if (keepHistory) await fs.mkdir(RUN_HISTORY_LOCK_DIR, { recursive: true, mode: 0o700 });
|
|
46
41
|
|
|
47
42
|
const wrap = (command: string[], cwd: string) => [
|
|
48
43
|
bwrap,
|
|
@@ -61,8 +56,6 @@ export async function openLinuxOverlay(repo: string, tempDir: string): Promise<O
|
|
|
61
56
|
upper,
|
|
62
57
|
work,
|
|
63
58
|
repo,
|
|
64
|
-
...(keepHistory ? ["--bind", logFile, logFile] : []),
|
|
65
|
-
...(keepHistory ? ["--bind", RUN_HISTORY_LOCK_DIR, RUN_HISTORY_LOCK_DIR] : []),
|
|
66
59
|
"--tmpfs",
|
|
67
60
|
"/dev/shm",
|
|
68
61
|
"--chdir",
|
package/overlay-macos.ts
CHANGED
|
@@ -31,7 +31,9 @@ export async function openMacOverlay(repo: string, tempDir: string): Promise<Ove
|
|
|
31
31
|
const base = path.join(tempDir, "base");
|
|
32
32
|
const mountContainer = await fs.mkdtemp(path.join(await fs.realpath(tmpdir()), "pi-shorthand-workspace-"));
|
|
33
33
|
const mount = path.join(mountContainer, "repo");
|
|
34
|
+
const scratch = path.join(mountContainer, "tmp");
|
|
34
35
|
await fs.mkdir(mount);
|
|
36
|
+
await fs.mkdir(scratch);
|
|
35
37
|
const state: RecoveryState = { runnerPid: process.pid, tempDir, mountContainer, mount };
|
|
36
38
|
await writeRecoveryState(stateFile, state);
|
|
37
39
|
|
|
@@ -48,11 +50,12 @@ export async function openMacOverlay(repo: string, tempDir: string): Promise<Ove
|
|
|
48
50
|
originalDir: base,
|
|
49
51
|
writableDir: mount,
|
|
50
52
|
executionDir: mount,
|
|
53
|
+
environment: { TMPDIR: scratch, TMP: scratch, TEMP: scratch },
|
|
51
54
|
gitExcludes: ["._*"],
|
|
52
55
|
wrap: (command) => [
|
|
53
56
|
"/usr/bin/sandbox-exec",
|
|
54
57
|
"-p",
|
|
55
|
-
sandboxProfile(repo, tempDir, mount, stateFile, gitMetadata, processDeniedCanary, cleanupHelper),
|
|
58
|
+
sandboxProfile(repo, tempDir, mount, stateFile, gitMetadata, processDeniedCanary, cleanupHelper, scratch),
|
|
56
59
|
...command,
|
|
57
60
|
],
|
|
58
61
|
terminateProcesses: async () => {
|
|
@@ -199,7 +202,9 @@ async function serveAndMount(
|
|
|
199
202
|
try {
|
|
200
203
|
await onSpawn(server.pid);
|
|
201
204
|
await waitForPort(port);
|
|
202
|
-
|
|
205
|
+
// AgentFS copy-up can change directory attributes. Stale NFS directory caches can make
|
|
206
|
+
// getcwd() fail in nested directories; keep file caching but revalidate directories immediately.
|
|
207
|
+
const options = `locallocks,vers=3,tcp,port=${port},mountport=${port},soft,timeo=100,retrans=5,acdirmin=0,acdirmax=0`;
|
|
203
208
|
await $`/sbin/mount_nfs -o ${options} 127.0.0.1:/ ${mount}`.quiet();
|
|
204
209
|
return server;
|
|
205
210
|
} catch (error) {
|
|
@@ -209,8 +214,8 @@ async function serveAndMount(
|
|
|
209
214
|
}
|
|
210
215
|
}
|
|
211
216
|
|
|
212
|
-
/**
|
|
213
|
-
function sandboxProfile(
|
|
217
|
+
/** Restrict writes to the workspace, private scratch space and devices. */
|
|
218
|
+
export function sandboxProfile(
|
|
214
219
|
repo: string,
|
|
215
220
|
tempDir: string,
|
|
216
221
|
mount: string,
|
|
@@ -218,10 +223,15 @@ function sandboxProfile(
|
|
|
218
223
|
gitMetadata: string[],
|
|
219
224
|
processDeniedCanary: string,
|
|
220
225
|
cleanupHelper: string,
|
|
226
|
+
scratch: string,
|
|
221
227
|
): string {
|
|
222
228
|
return [
|
|
223
229
|
"(version 1)",
|
|
224
230
|
"(allow default)",
|
|
231
|
+
"(deny file-write*)",
|
|
232
|
+
`(allow file-write* (require-all (subpath ${JSON.stringify(mount)}) (require-not (subpath ${JSON.stringify(path.join(mount, ".git"))}))))`,
|
|
233
|
+
`(allow file-write* (subpath ${JSON.stringify(scratch)}))`,
|
|
234
|
+
'(allow file-write-data (literal "/dev/null") (literal "/dev/tty"))',
|
|
225
235
|
`(deny file-read* (subpath ${JSON.stringify(repo)}))`,
|
|
226
236
|
`(deny file-write* (subpath ${JSON.stringify(repo)}))`,
|
|
227
237
|
`(deny file-write* (subpath ${JSON.stringify(tempDir)}))`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-shorthand",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Pi tool for editing repositories with Bun programs, text edits and structural transformations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ast-grep",
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"lint": "oxlint --deny-warnings",
|
|
34
34
|
"format": "oxfmt",
|
|
35
35
|
"check": "tsc -p . && oxlint --deny-warnings && oxfmt --check",
|
|
36
|
-
"
|
|
36
|
+
"setup:grit": "grit init --global",
|
|
37
37
|
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
package/placement.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
4
|
import { Lang, parse, type SgNode } from "@ast-grep/napi";
|
|
5
|
+
import { editingFiles } from "./file-outcomes.ts";
|
|
5
6
|
|
|
6
7
|
export interface Match {
|
|
7
8
|
file: string;
|
|
@@ -291,11 +292,23 @@ function apply(plans: { saved: Snapshot; edits: Edit[] }[]) {
|
|
|
291
292
|
}
|
|
292
293
|
|
|
293
294
|
export function insert(text: string, destination: Destination): void {
|
|
295
|
+
return editingFiles(destinationFiles(destination), () => insertNodes(text, destination));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function destinationFiles(destination: Destination): string[] {
|
|
299
|
+
return Object.values(destination).flatMap((match) => (typeof match?.file === "string" ? [match.file] : []));
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function insertNodes(text: string, destination: Destination): void {
|
|
294
303
|
const { saved, edit } = placement(text, destination);
|
|
295
304
|
apply([{ saved, edits: [edit] }]);
|
|
296
305
|
}
|
|
297
306
|
|
|
298
307
|
export function move(match: Match, destination: Destination, transform?: (text: string) => string): void {
|
|
308
|
+
return editingFiles([match.file, ...destinationFiles(destination)], () => moveNodes(match, destination, transform));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function moveNodes(match: Match, destination: Destination, transform?: (text: string) => string): void {
|
|
299
312
|
const source = snapshot(match);
|
|
300
313
|
const deletion = removal(source);
|
|
301
314
|
const text = transform ? transform(source.node.text()) : source.node.text();
|
|
@@ -322,6 +335,14 @@ export function move(match: Match, destination: Destination, transform?: (text:
|
|
|
322
335
|
}
|
|
323
336
|
|
|
324
337
|
export function remove(matches: Match | readonly Match[]): void {
|
|
338
|
+
const selected = Array.isArray(matches) ? matches : [matches as Match];
|
|
339
|
+
return editingFiles(
|
|
340
|
+
selected.map((match) => match.file),
|
|
341
|
+
() => removeNodes(matches),
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function removeNodes(matches: Match | readonly Match[]): void {
|
|
325
346
|
const plans = new Map<string, { saved: Snapshot; edits: Edit[] }>();
|
|
326
347
|
for (const match of Array.isArray(matches) ? matches : [matches as Match]) {
|
|
327
348
|
const saved = snapshot(match);
|