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 CHANGED
@@ -1,93 +1,33 @@
1
1
  # pi-shorthand
2
2
 
3
- ![A code call in Pi: the verdict, then the diff it applied, then the program's output](https://raw.githubusercontent.com/sebinsua/pi-shorthand/main/docs/screenshot.png?v=2)
3
+ ![A code call in Pi](https://raw.githubusercontent.com/sebinsua/pi-shorthand/main/docs/screenshot.png?v=2)
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
- Or from GitHub (`pi install git:github.com/sebinsua/pi-shorthand`), or a local clone: `npm install`, then `pi install /path/to/pi-shorthand`.
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
- ## What a program can use
27
-
28
- Anything in Bun or Node, plus these globals (no imports):
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
- `sg.find`, `sg.one`, `sg.rewrite` and `grit` also accept `sg.file("src/app.ts")` directly, including
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
- [api.d.ts](api.d.ts) exposes the actual injected helper types for editor completion and external TypeScript
49
- checking of editing programs. Include it in the program's TypeScript project (or reference
50
- `pi-shorthand/api` via `compilerOptions.types` when installed as a package). This supplies types, not runtime
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
- `npm run check` type-checks (TypeScript 7), lints (oxlint) and checks formatting (oxfmt). A pre-commit
84
- hook runs it; `npm run format` fixes formatting.
29
+ ## Technical choices
85
30
 
86
- `npm test` runs the tests against real overlays (it needs AgentFS on macOS, bubblewrap on Linux).
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
- `bun e2e/suite.ts` previews the local benchmark suite without calling a model. Add `--execute` to run it.
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 === "file" && "rollback per file", args.timeout && `timeout ${args.timeout}s`];
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 at the timeout"
73
- : run.timedOut
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 = run.exitCode === 0 && run.conflicts.length === 0 ? "toolSuccessBg" : "toolErrorBg";
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) {
@@ -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 { closeSync, openSync, readSync, statSync, writeFileSync } from "node:fs";
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 (event.toolName === "code" && run && (run.exitCode !== 0 || run.conflicts.length > 0)) return { isError: true };
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 transactional Bun editing program; run verification separately afterward",
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: "all" (default) applies nothing; "file" retains files closed before a timeout when writer inspection succeeds',
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 from the log.
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 ?? "all",
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(options: RunOptions, signal?: AbortSignal): Promise<RunResult> {
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")], { detached: true });
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
- const options = `locallocks,vers=3,tcp,port=${port},mountport=${port},soft,timeo=100,retrans=5`;
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
- /** The program may write only to its private mount, excluding the real checkout and Git metadata. */
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.0",
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
- "postinstall": "grit init --global",
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);