pi-shorthand 0.3.1 → 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,100 +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
- The optional `grit()` helper needs Grit’s modules initialized before sandboxed use. Run
27
- `npm run setup:grit` from this package’s directory if you want to use it. This explicitly runs
28
- `grit init --global` and changes your user-level Grit state; package installation does not run it.
29
-
30
- ## What a program can use
31
-
32
- Anything in Bun or Node, plus these globals (no imports):
33
-
34
- ```ts
35
- await $`git ls-files`.text(); // Bun's shell (the only async one)
36
- glob("src/**/*.ts"); // → ["src/a.ts", …]
37
- grep("oldApi(", "src"); // → [{ file, line, text }, …]
38
- sg.find("oldApi($$$ARGS)", "src"); // ast-grep search
39
- sg.rewrite("oldApi($$$ARGS)", "newApi($$$ARGS)", "src");
40
- sg.insert("initialize();", { before: sg.one("run();", "src/app.ts") });
41
- sg.move(sg.one("function helper() { $$$BODY }", "src/old.ts"), { endOf: sg.file("src/new.ts") });
42
- sg.remove(sg.one("obsolete();", "src/app.ts"));
43
- sg.parse(sg.Lang.TypeScript, source); // ast-grep's own API (or import from "@ast-grep/napi")
44
- grit("`console.log($x)` => `logger.info($x)`", "src");
18
+ ```sh
19
+ xcode-select --install
20
+ curl -fsSL https://agentfs.ai/install | bash
45
21
  ```
46
22
 
47
- `sg.find`, `sg.one`, `sg.rewrite` and `grit` also accept `sg.file("src/app.ts")` directly, including
48
- in arrays mixed with paths. Search reads the file's current contents. Missing targets are valid insertion
49
- destinations but cannot be searched. An explicit target can select an ignored file inside the workspace;
50
- the tool still only applies Git-visible changes.
23
+ On Linux, install bubblewrap 0.9 or later. For Debian and Ubuntu:
51
24
 
52
- [api.d.ts](api.d.ts) exposes the actual injected helper types for editor completion and external TypeScript
53
- checking of editing programs. Include it in the program's TypeScript project (or reference
54
- `pi-shorthand/api` via `compilerOptions.types` when installed as a package). This supplies types, not runtime
55
- globals. Bun execution does not automatically type-check programs or run application verification.
56
-
57
- ## Options
58
-
59
- - `title`: a short description shown with the call (required).
60
- - `rollback`: `"all"` (default) applies nothing if the program fails. After a timeout, `"file"`
61
- keeps changed files that were no longer open for writing if writer inspection succeeds. Other
62
- failures apply nothing because open writers cannot be identified after the process exits.
63
- - `timeout`: seconds before the program is killed. Default 2.
64
-
65
- ## Good to know
66
-
67
- - Only files git tracks, or would track, are applied.
68
- - Successful edits are formatted with detected installed project tools (Prettier, oxfmt, Biome, Ruff,
69
- Black, gofmt or rustfmt) before the final diff. Ambiguous setups are skipped; formatter failures warn
70
- without discarding completed edits. Set `PI_SHORTHAND_FORMAT=0` to disable. No project config is required.
71
- - Each run snapshots the checkout first; reflinks make that cheap where supported, while other
72
- filesystems copy its contents and use corresponding temporary space. On macOS the program runs at
73
- a private AgentFS mount, so use paths relative to its working directory for repository files.
74
- - Generated programs can write to the private workspace and run-specific temporary space; host files
75
- outside those roots are read-only, including targets reached through repository symlinks. Run-history
76
- logging has a narrow write exception. On macOS, the live checkout is also unreadable.
77
- - Runs against the same checkout are serialized. If another process edits a destination while a run
78
- is in progress, shorthand checks it again immediately before replacing it and reports a conflict.
79
- A non-cooperating writer can still race the final filesystem rename or removal itself.
80
- - To try it with only `read` and `code`: `pi --tools read,code`.
81
- - Run history is stored in `~/.cache/pi-shorthand/runs.jsonl` with directory mode `0700` and file
82
- mode `0600`. It records timestamps, opaque run IDs, lifecycle events, exit status, durations,
83
- counts, helper names, and shell executable names. It does not record programs, output, errors,
84
- arguments, repository paths, file paths, or diffs. The log rotates at 1 MiB and expires after
85
- seven days. Set `PI_SHORTHAND_HISTORY=0` to disable it. To watch enabled history:
86
- `tail -f ~/.cache/pi-shorthand/runs.jsonl`.
87
-
88
- ## Developing
25
+ ```sh
26
+ sudo apt install bubblewrap
27
+ ```
89
28
 
90
- `npm run check` type-checks (TypeScript 7), lints (oxlint) and checks formatting (oxfmt). A pre-commit
91
- hook runs it; `npm run format` fixes formatting.
29
+ ## Technical choices
92
30
 
93
- `npm test` runs the tests against real overlays (it needs AgentFS on macOS, bubblewrap on Linux).
94
- `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.
95
32
 
96
- `bun e2e/suite.ts` previews the local benchmark suite without calling a model. Add `--execute` to run it.
97
- The [comparison harness](e2e/README.md) supports stock, optional and replacement editing tools, controlled
98
- documentation/skills, independent task checks, saved final changes and session reports.
99
- `bun e2e/run.ts --repo <path or git URL> --task "…" --setups baseline,replace,code --check "…"` runs Pi
100
- 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
@@ -11,7 +11,6 @@ import { homedir, tmpdir } from "node:os";
11
11
  import * as path from "node:path";
12
12
  import { $ } from "bun";
13
13
  import { Database } from "bun:sqlite";
14
- import { historyEnabled, RUN_HISTORY_FILE, RUN_HISTORY_LOCK_DIR } from "./history.ts";
15
14
  import { copyStableTree } from "./overlay-linux.ts";
16
15
  import type { FilesystemEntry, Overlay } from "./runner.ts";
17
16
 
@@ -203,7 +202,9 @@ async function serveAndMount(
203
202
  try {
204
203
  await onSpawn(server.pid);
205
204
  await waitForPort(port);
206
- 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`;
207
208
  await $`/sbin/mount_nfs -o ${options} 127.0.0.1:/ ${mount}`.quiet();
208
209
  return server;
209
210
  } catch (error) {
@@ -213,7 +214,7 @@ async function serveAndMount(
213
214
  }
214
215
  }
215
216
 
216
- /** Restrict writes to the workspace, private scratch space, devices and optional run history. */
217
+ /** Restrict writes to the workspace, private scratch space and devices. */
217
218
  export function sandboxProfile(
218
219
  repo: string,
219
220
  tempDir: string,
@@ -231,12 +232,6 @@ export function sandboxProfile(
231
232
  `(allow file-write* (require-all (subpath ${JSON.stringify(mount)}) (require-not (subpath ${JSON.stringify(path.join(mount, ".git"))}))))`,
232
233
  `(allow file-write* (subpath ${JSON.stringify(scratch)}))`,
233
234
  '(allow file-write-data (literal "/dev/null") (literal "/dev/tty"))',
234
- ...(historyEnabled()
235
- ? [
236
- `(allow file-write-data (literal ${JSON.stringify(RUN_HISTORY_FILE)}))`,
237
- `(allow file-write* (subpath ${JSON.stringify(RUN_HISTORY_LOCK_DIR)}))`,
238
- ]
239
- : []),
240
235
  `(deny file-read* (subpath ${JSON.stringify(repo)}))`,
241
236
  `(deny file-write* (subpath ${JSON.stringify(repo)}))`,
242
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.1",
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",
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);
package/prelude.ts CHANGED
@@ -9,16 +9,15 @@
9
9
  * File lists come from git (tracked, plus untracked files that aren't ignored), so node_modules
10
10
  * and build output are left out on every platform.
11
11
  *
12
- * Each $ command and helper call is logged to the runner's log (~/.cache/pi-shorthand/runs.jsonl) as it
13
- * happens, so `tail -f` shows what a program is doing, including which command it's stuck on.
12
+ * Each $ command and helper call is reported to the runner while the program is active.
14
13
  */
15
14
 
16
- import { lstatSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
15
+ import { lstatSync, readFileSync, realpathSync, statSync, writeFileSync, writeSync } from "node:fs";
17
16
  import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path";
18
17
  import * as astGrep from "@ast-grep/napi";
19
18
  import { type Edit, Lang, type NapiConfig, parse, type SgNode } from "@ast-grep/napi";
20
19
  import { $ as bunShell, Glob } from "bun";
21
- import { appendRunHistory } from "./history.ts";
20
+ import { editingFiles, installFileOutcomeTracking } from "./file-outcomes.ts";
22
21
  import {
23
22
  file as selectFile,
24
23
  getMatchSnapshot,
@@ -30,10 +29,19 @@ import {
30
29
  type FileTarget,
31
30
  } from "./placement.ts";
32
31
 
33
- function log(event: string, details: Record<string, unknown>) {
34
- const { PI_SHORTHAND_LOG, PI_SHORTHAND_RUN } = process.env;
35
- if (!PI_SHORTHAND_LOG) return;
36
- appendRunHistory(event, { run: PI_SHORTHAND_RUN, ...details }, PI_SHORTHAND_LOG);
32
+ installFileOutcomeTracking();
33
+
34
+ const progressDescriptor = process.env.PI_SHORTHAND_PROGRESS_FD;
35
+ // Subprocesses do not inherit this descriptor by default, so do not advertise it to them.
36
+ delete process.env.PI_SHORTHAND_PROGRESS_FD;
37
+
38
+ function report(event: Record<string, unknown>) {
39
+ if (!progressDescriptor) return;
40
+ try {
41
+ writeSync(Number(progressDescriptor), JSON.stringify(event) + "\n");
42
+ } catch {
43
+ // Progress is advisory and must never change the program's result.
44
+ }
37
45
  }
38
46
 
39
47
  /** Runs a helper, logging how long it took and how many results it returned. */
@@ -41,7 +49,8 @@ function logged<T>(helper: string, _args: unknown[], run: () => T): T {
41
49
  const startedAt = performance.now();
42
50
  const result = run();
43
51
  const results = Array.isArray(result) ? result.length : typeof result === "number" ? result : undefined;
44
- log("helper", {
52
+ report({
53
+ type: "helper",
45
54
  helper,
46
55
  ms: Math.round(performance.now() - startedAt),
47
56
  results,
@@ -54,7 +63,7 @@ const $ = new Proxy(bunShell, {
54
63
  apply(target, thisArg, args: Parameters<typeof bunShell>) {
55
64
  const [strings] = args;
56
65
  const words = strings.raw[0].trim().split(/\s+/);
57
- log("command", { command: words.find((word) => !word.includes("=")) ?? "" });
66
+ report({ type: "command", command: words.find((word) => !word.includes("=")) ?? "" });
58
67
  return Reflect.apply(target, thisArg, args);
59
68
  },
60
69
  });
@@ -354,7 +363,7 @@ function rewrite(...[target, replacement, files]: RewriteArgs): number {
354
363
  const groups = new Map<string, SgMatch[]>();
355
364
  const sources = new Map<string, string | null>();
356
365
  for (const match of (Array.isArray(target) ? target : [target]) as SgMatch[]) {
357
- const saved = getMatchSnapshot(match, sources, rewriteStaleAdvice);
366
+ const saved = editingFiles([match.file], () => getMatchSnapshot(match, sources, rewriteStaleAdvice));
358
367
  if (!match.vars || typeof match.line !== "number")
359
368
  throw new Error("sg.rewrite expects matches from sg.one or sg.find");
360
369
  const file = explicitPath(match.file);
@@ -365,9 +374,11 @@ function rewrite(...[target, replacement, files]: RewriteArgs): number {
365
374
  }
366
375
  let count = 0;
367
376
  for (const [file, matches] of groups) {
368
- const currentSources = new Map<string, string | null>();
369
- for (const match of matches) getMatchSnapshot(match, currentSources, rewriteStaleAdvice);
370
- count += applyRewrites(matches, replacement, file);
377
+ count += editingFiles([file], () => {
378
+ const currentSources = new Map<string, string | null>();
379
+ for (const match of matches) getMatchSnapshot(match, currentSources, rewriteStaleAdvice);
380
+ return applyRewrites(matches, replacement, file);
381
+ });
371
382
  }
372
383
  return count;
373
384
  }
@@ -376,13 +387,15 @@ function rewrite(...[target, replacement, files]: RewriteArgs): number {
376
387
  let count = 0,
377
388
  matched = 0;
378
389
  for (const file of sourceFiles("sg.rewrite", scope)) {
379
- const parsed = parseFile(file);
380
- if (!parsed) continue;
381
- const matches = findNodes("sg.rewrite", parsed.root, pattern).map((node) =>
382
- toMatch(file, node, parsed.source, pattern),
383
- );
384
- matched += matches.length;
385
- count += applyRewrites(matches, replacement, file);
390
+ count += editingFiles([file], () => {
391
+ const parsed = parseFile(file);
392
+ if (!parsed) return 0;
393
+ const matches = findNodes("sg.rewrite", parsed.root, pattern).map((node) =>
394
+ toMatch(file, node, parsed.source, pattern),
395
+ );
396
+ matched += matches.length;
397
+ return applyRewrites(matches, replacement, file);
398
+ });
386
399
  }
387
400
  if (matched === 0) {
388
401
  const paths = (Array.isArray(scope) ? scope : [scope]).map((entry) =>
@@ -438,6 +451,15 @@ function grit(pattern: string, paths: FileScope = ".", options: { lang?: string;
438
451
  if (options.lang) flags.push("--language", options.lang);
439
452
 
440
453
  const targets = selected.map((file) => resolve(repositoryRoot, file));
454
+
455
+ if (options.dryRun) return applyGrit(pattern, flags, targets);
456
+ const affected = targets.flatMap((file) =>
457
+ statSync(file).isDirectory() ? selectFiles(file).map((entry) => resolve(repositoryRoot, entry)) : [file],
458
+ );
459
+ return editingFiles(affected, () => applyGrit(pattern, flags, targets));
460
+ }
461
+
462
+ function applyGrit(pattern: string, flags: string[], targets: string[]) {
441
463
  const result = Bun.spawnSync(["grit", "apply", ...flags, pattern, ...targets], { env: process.env });
442
464
  if (result.exitCode !== 0) {
443
465
  const diagnostic = result.stderr.toString().trim() || result.stdout.toString().trim();
@@ -504,7 +526,10 @@ function editText({ path, oldText, newText }: { path: string; oldText: string; n
504
526
 
505
527
  const globals = {
506
528
  $,
507
- edit: (...args: Parameters<typeof editText>) => logged("edit", args, () => editText(...args)),
529
+ edit: (...args: Parameters<typeof editText>) =>
530
+ logged("edit", args, () =>
531
+ editingFiles(typeof args[0]?.path === "string" ? [args[0].path] : [], () => editText(...args)),
532
+ ),
508
533
  glob: (...args: Parameters<typeof glob>) => logged("glob", args, () => glob(...args)),
509
534
  grep: (...args: Parameters<typeof grep>) => logged("grep", args, () => grep(...args)),
510
535
  sg: {
package/runner.ts CHANGED
@@ -4,31 +4,29 @@
4
4
  *
5
5
  * Usage: echo '<RunOptions as JSON>' | bun runner.ts → prints a RunResult as JSON
6
6
  * SIGTERM aborts: the program is killed, the overlay is closed and nothing is applied.
7
- * Each step is logged to ~/.cache/pi-shorthand/runs.jsonl, so `tail -f` shows what a run is doing.
7
+ * Command and helper progress is streamed to the parent process while the run is active.
8
8
  *
9
9
  * If the program fails:
10
10
  * - rollback "all": nothing is applied;
11
- * - rollback "file": on timeout, files that weren't open for writing are applied. On any other
12
- * failure, nothing is applied because the process has exited and its open files can't be inspected.
11
+ * - rollback "file": failed or interrupted file edits are discarded; other files are applied.
13
12
  */
14
13
 
15
14
  import { type ChildProcess, spawn } from "node:child_process";
16
15
  import { createHash, randomUUID } from "node:crypto";
17
- import { constants, type Stats } from "node:fs";
16
+ import { constants, type Stats, writeSync } from "node:fs";
18
17
  import * as fs from "node:fs/promises";
19
18
  import { homedir, tmpdir } from "node:os";
20
19
  import * as path from "node:path";
21
20
  import { Lang, parse } from "@ast-grep/napi";
22
21
  import { $ } from "bun";
23
22
  import { structuredPatch } from "diff";
24
- import { appendRunHistory, historyEnabled, RUN_HISTORY_FILE } from "./history.ts";
25
23
  import { openLinuxOverlay } from "./overlay-linux.ts";
26
24
  import { openMacOverlay } from "./overlay-macos.ts";
27
25
  import { discardedEdits } from "./program-lint.ts";
28
26
  import { preserveTextFormat } from "./text-format.ts";
27
+ import { type FileOutcomeEvent, parseOpenWriters } from "./file-outcomes.ts";
29
28
 
30
29
  export interface RunOptions {
31
- runId: string; // identifies this run's events in the log
32
30
  cwd: string;
33
31
  program: string;
34
32
  timeoutMs: number;
@@ -64,7 +62,7 @@ export interface RunResult {
64
62
  applied: string[]; // the changed files that were applied
65
63
  conflicts: string[]; // destinations changed after the run's baseline was captured
66
64
  rolledBack: string[]; // rollback "file": changed files not retained because completion was not established
67
- writerInspectionFailed: boolean; // timeout: inspection failed, so no changed file was retained
65
+ writerInspectionFailed: boolean; // inspection unavailable: no changed file was retained on failure
68
66
  stillRunning: string[]; // on timeout: commands the program was still running, e.g. "find / -name x (for 58s)"
69
67
  lastStep?: string; // on timeout: the last step the program logged, e.g. "$ find / -name x" or "grep (18 ms)"
70
68
  errorLine?: string; // on failure: the program's line the error came from, e.g. "line 3: throw new Error(…)"
@@ -113,12 +111,6 @@ const PRELUDE = path.join(import.meta.dir, "prelude.ts");
113
111
  // (e.g. Pi's project installs), one further up. Like `npm run`, look in every one from here up.
114
112
  const NODE_MODULES = ancestors(import.meta.dir).map((dir) => path.join(dir, "node_modules"));
115
113
  const MAX_OUTPUT_CHARS = 1024 * 1024; // a safety cap; index.ts decides how much the model sees
116
- let RUN_ID = ""; // set from RunOptions when the runner starts
117
-
118
- /** Appends one event to the log, e.g. log("program exited", { exitCode: 0 }). */
119
- function log(event: string, details: Record<string, unknown> = {}) {
120
- appendRunHistory(event, { run: RUN_ID, ...details });
121
- }
122
114
 
123
115
  async function run(options: RunOptions, abort: AbortSignal): Promise<RunResult> {
124
116
  const startedAt = performance.now();
@@ -133,10 +125,8 @@ async function run(options: RunOptions, abort: AbortSignal): Promise<RunResult>
133
125
 
134
126
  try {
135
127
  tempDir = await fs.mkdtemp(path.join(await fs.realpath(tmpdir()), "pi-shorthand-"));
136
- log("started", { timeoutMs: options.timeoutMs, rollback: options.rollback });
137
128
  const open = process.platform === "darwin" ? openMacOverlay : openLinuxOverlay;
138
129
  const overlay = await open(repo, tempDir);
139
- log("overlay opened");
140
130
  let program: ProgramRun;
141
131
  let changes: Change[];
142
132
  let executionError: unknown;
@@ -155,7 +145,13 @@ async function run(options: RunOptions, abort: AbortSignal): Promise<RunResult>
155
145
  changes = changes.filter((change) => !entriesEqual(change.before, change.after));
156
146
  }
157
147
  const files = changes.filter((change) => change.after?.type === "file").map((change) => change.file);
158
- if (program.exitCode === 0 && !abort.aborted && files.length && process.env.PI_SHORTHAND_FORMAT !== "0") {
148
+ if (
149
+ program.exitCode === 0 &&
150
+ program.failedFiles.length === 0 &&
151
+ !abort.aborted &&
152
+ files.length &&
153
+ process.env.PI_SHORTHAND_FORMAT !== "0"
154
+ ) {
159
155
  try {
160
156
  const module = executionPath(path.join(import.meta.dir, "format.ts"), repo, overlay);
161
157
  const formatting = await runProgram(
@@ -218,12 +214,6 @@ console.log(JSON.stringify(await formatChanged(${JSON.stringify(files)}, process
218
214
  warnings: applicationWarnings,
219
215
  } = await applyChanges(repo, requested, { abort, testHooks: options.testHooks?.apply }));
220
216
  }
221
- log("finished", {
222
- changed: changes.length,
223
- applied: applied.length,
224
- conflicts: conflicts.length,
225
- });
226
-
227
217
  result = {
228
218
  exitCode: program.exitCode,
229
219
  timedOut: program.timedOut,
@@ -237,7 +227,7 @@ console.log(JSON.stringify(await formatChanged(${JSON.stringify(files)}, process
237
227
  rolledBack: rolledBack.map((change) => shown(change.file)),
238
228
  writerInspectionFailed: program.openForWriting === null,
239
229
  stillRunning: program.stillRunning,
240
- lastStep: program.timedOut ? await lastLoggedStep() : undefined,
230
+ lastStep: program.timedOut ? program.lastStep : undefined,
241
231
  errorLine: program.exitCode !== 0 ? failingLine(options.program, program.output) : undefined,
242
232
  timeoutMs: options.timeoutMs,
243
233
  rollback: options.rollback,
@@ -276,7 +266,6 @@ async function closeOverlay(overlay: Overlay, injectFailure = false) {
276
266
  function cleanupWarning(resource: string, error: unknown): string {
277
267
  const detail = error instanceof Error ? error.message : String(error);
278
268
  const warning = `The run reached its reported outcome, but cleanup of its ${resource} failed: ${detail}`;
279
- log("cleanup warning", { warning });
280
269
  return warning;
281
270
  }
282
271
 
@@ -390,17 +379,17 @@ async function safeParentChain(repo: string, target: string, allowMissing = fals
390
379
  return true;
391
380
  }
392
381
 
393
- /** Preserve provably closed files only when a timeout lets us inspect the still-running process. */
382
+ /** Retain independent file edits, excluding failed operations and interrupted writers. */
394
383
  function whatToApply(changes: Change[], program: ProgramRun, rollback: RunOptions["rollback"], aborted: boolean) {
395
384
  if (aborted) return { applied: [], rolledBack: [] };
396
- if (program.exitCode === 0) return { applied: changes, rolledBack: [] };
397
- if (rollback === "all") return { applied: [], rolledBack: [] };
398
- if (!program.timedOut) return { applied: [], rolledBack: changes };
385
+ if (rollback === "all") return { applied: program.exitCode === 0 ? changes : [], rolledBack: [] };
399
386
  if (program.openForWriting === null) return { applied: [], rolledBack: changes };
400
387
 
401
- const openForWriting = program.openForWriting;
402
- const halfWritten = (change: Change) => openForWriting.includes(change.file);
403
- return { applied: changes.filter((change) => !halfWritten(change)), rolledBack: changes.filter(halfWritten) };
388
+ const failed = new Set([...program.failedFiles, ...program.openForWriting]);
389
+ return {
390
+ applied: changes.filter((change) => !failed.has(change.file)),
391
+ rolledBack: changes.filter((change) => failed.has(change.file)),
392
+ };
404
393
  }
405
394
 
406
395
  /** A `$` command that isn't awaited never runs: Bun's shell starts a command when it's awaited. */
@@ -441,8 +430,10 @@ interface ProgramRun {
441
430
  exitCode: number | null;
442
431
  timedOut: boolean;
443
432
  output: string;
444
- openForWriting: string[] | null; // on timeout: open writers, or null when inspection failed
433
+ openForWriting: string[] | null; // on failure/timeout: open writers, or null when inspection was unavailable
434
+ failedFiles: string[];
445
435
  stillRunning: string[]; // on timeout: the commands it was still running
436
+ lastStep?: string;
446
437
  }
447
438
 
448
439
  async function runProgram(
@@ -468,22 +459,29 @@ async function runProgram(
468
459
  // detached: the program gets its own process group, so killing the group kills anything it started too.
469
460
  const outputFile = path.join(tempDir, "output");
470
461
  const output = await fs.open(outputFile, "w");
462
+ const trackFiles = options.rollback === "file";
463
+ const outcomePath = path.join(tempDir, "file-outcomes");
464
+ const outcomeFile = await fs.open(outcomePath, "w");
471
465
  const [command, ...args] = overlay.wrap(
472
466
  [process.execPath, "--preload", executionPrelude, executionProgramPath],
473
467
  executionCwd,
474
468
  );
475
- log("program started", { timeoutMs: options.timeoutMs });
476
469
  if (options.testHooks?.programStartMarker) await Bun.write(options.testHooks.programStartMarker, "started");
477
470
  const child = spawn(command, args, {
478
471
  cwd: executionCwd,
479
472
  detached: true,
480
- stdio: ["ignore", output.fd, output.fd],
473
+ stdio: ["ignore", output.fd, output.fd, trackFiles ? outcomeFile.fd : "ignore", "pipe"],
481
474
  env: {
482
475
  ...process.env,
483
476
  ...overlay.environment,
484
477
  ...programEnvironment(excludesFile, repo, overlay),
478
+ PI_SHORTHAND_OUTCOMES_FD: trackFiles ? "3" : "",
479
+ PI_SHORTHAND_PROGRESS_FD: "4",
480
+ PI_SHORTHAND_EXECUTION_ROOT: overlay.executionDir,
481
+ PI_SHORTHAND_INSPECTION_FAILURE: options.testHooks?.writerInspectionFailure ? "1" : "",
485
482
  },
486
483
  });
484
+ const progress = trackProgress(child);
487
485
  const killAll = () => killGroup(child);
488
486
  abort.addEventListener("abort", killAll);
489
487
  if (abort.aborted) killAll();
@@ -494,15 +492,16 @@ async function runProgram(
494
492
  overlay.executionDir,
495
493
  options.testHooks?.writerInspectionFailure,
496
494
  );
497
- log("program exited", { exitCode, timedOut, aborted: abort.aborted, stillRunning: stillRunning.length });
498
495
  killAll(); // anything it left running
499
496
  abort.removeEventListener("abort", killAll);
500
497
  try {
501
498
  await overlay.terminateProcesses?.();
502
499
  } finally {
503
- await output.close();
500
+ await Promise.all([output.close(), outcomeFile.close()]);
501
+ await progress.closed;
504
502
  await fs.rm(programFile, { force: true });
505
503
  }
504
+ const outcomes = fileOutcomes(await Bun.file(outcomePath).text());
506
505
 
507
506
  // Keep the tail, where errors are. Show stack traces as "program.ts:3:11", and drop Bun's version footer.
508
507
  let text = await Bun.file(outputFile).text();
@@ -511,7 +510,69 @@ async function runProgram(
511
510
  }
512
511
  text = text.replaceAll(executionProgramPath, "program.ts").replace(/\nBun v[\d.]+ \([^)]*\)\n?$/, "\n");
513
512
 
514
- return { exitCode, timedOut, output: text, openForWriting, stillRunning };
513
+ return {
514
+ exitCode,
515
+ timedOut,
516
+ output: text,
517
+ openForWriting: trackFiles && exitCode !== 0 && !timedOut ? outcomes.writers : openForWriting,
518
+ stillRunning,
519
+ failedFiles: outcomes.failedFiles,
520
+ lastStep: progress.latest(),
521
+ };
522
+ }
523
+
524
+ /** Keep the latest command/helper in memory and relay it to index.ts over the runner's descriptor 3. */
525
+ function trackProgress(child: ChildProcess) {
526
+ const stream = child.stdio[4];
527
+ let buffer = "";
528
+ let lastStep: string | undefined;
529
+ const closed = new Promise<void>((resolve) => {
530
+ if (!stream || !("setEncoding" in stream)) return resolve();
531
+ stream.setEncoding("utf8");
532
+ stream.on("data", (chunk) => {
533
+ buffer += chunk;
534
+ const lines = buffer.split("\n");
535
+ buffer = lines.pop() ?? "";
536
+ for (const line of lines) {
537
+ try {
538
+ const event = JSON.parse(line) as { type?: unknown; command?: unknown; helper?: unknown; ms?: unknown };
539
+ if (event.type === "command" && typeof event.command === "string") lastStep = `$ ${event.command}`;
540
+ else if (event.type === "helper" && typeof event.helper === "string" && typeof event.ms === "number")
541
+ lastStep = `${event.helper} (${event.ms} ms)`;
542
+ else continue;
543
+ try {
544
+ writeSync(3, JSON.stringify({ step: lastStep }) + "\n");
545
+ } catch {
546
+ // Direct runner callers do not provide a progress descriptor.
547
+ }
548
+ } catch {
549
+ // Progress is advisory; malformed events do not affect the run.
550
+ }
551
+ }
552
+ });
553
+ stream.once("end", resolve);
554
+ stream.once("error", resolve);
555
+ });
556
+ return { closed, latest: () => lastStep };
557
+ }
558
+
559
+ /** Replay helper outcomes after all program processes have stopped. */
560
+ function fileOutcomes(text: string) {
561
+ const active = new Map<number, string[]>();
562
+ const failed = new Set<string>();
563
+ let writers: string[] | null = null;
564
+ for (const line of text.split("\n").slice(0, -1).filter(Boolean)) {
565
+ const event = JSON.parse(line) as FileOutcomeEvent;
566
+ if (event.type === "begin") active.set(event.id, event.files);
567
+ else if (event.type === "end") active.delete(event.id);
568
+ else if (event.type === "fail") {
569
+ for (const file of active.get(event.id) ?? []) failed.add(file);
570
+ active.delete(event.id);
571
+ } else if (event.type === "error") {
572
+ for (const file of event.files) failed.add(file);
573
+ } else if (event.type === "writers") writers = event.files;
574
+ }
575
+ return { writers, failedFiles: [...new Set([...failed, ...[...active.values()].flat()])] };
515
576
  }
516
577
 
517
578
  /**
@@ -535,7 +596,12 @@ async function waitWithTimeout(child: ChildProcess, timeoutMs: number, repo: str
535
596
  ]);
536
597
  killGroup(child);
537
598
  await exited;
538
- return { exitCode: null, timedOut: true, openForWriting, stillRunning };
599
+ return {
600
+ exitCode: null,
601
+ timedOut: true,
602
+ openForWriting,
603
+ stillRunning,
604
+ };
539
605
  }
540
606
 
541
607
  /**
@@ -577,28 +643,6 @@ function seconds(elapsed: string): number {
577
643
  return Number(days) * 86400 + hours * 3600 + minutes * 60 + secs;
578
644
  }
579
645
 
580
- /** The last command or helper call this run's program logged, e.g. "$ find / -name x". */
581
- async function lastLoggedStep(): Promise<string | undefined> {
582
- let lines: string[];
583
- try {
584
- lines = (await Bun.file(RUN_HISTORY_FILE).text()).trimEnd().split("\n").slice(-500);
585
- } catch {
586
- return undefined;
587
- }
588
- for (const line of lines.toReversed()) {
589
- let event: Record<string, unknown>;
590
- try {
591
- event = JSON.parse(line);
592
- } catch {
593
- continue;
594
- }
595
- if (event.run !== RUN_ID) continue;
596
- if (event.event === "command") return `$ ${event.command}`;
597
- if (event.event === "helper") return `${event.helper} (${event.ms} ms)`;
598
- }
599
- return undefined;
600
- }
601
-
602
646
  function killGroup(child: ChildProcess) {
603
647
  try {
604
648
  process.kill(-child.pid!, "SIGKILL");
@@ -620,8 +664,6 @@ function programEnvironment(excludesFile: string, repo: string, overlay: Overlay
620
664
  // So programs can import the extension's own packages, e.g. "@ast-grep/napi". A repository's own
621
665
  // node_modules still wins: NODE_PATH is only a fallback.
622
666
  NODE_PATH: [...nodeModules, process.env.NODE_PATH].filter(Boolean).join(path.delimiter),
623
- ...(historyEnabled() ? { PI_SHORTHAND_LOG: RUN_HISTORY_FILE, PI_SHORTHAND_HISTORY_SANDBOX: "1" } : {}),
624
- PI_SHORTHAND_RUN: RUN_ID,
625
667
  GIT_OPTIONAL_LOCKS: "0", // read-only git commands should not dirty copied or mounted metadata
626
668
  GIT_CONFIG_COUNT: String(count + 1),
627
669
  [`GIT_CONFIG_KEY_${count}`]: "core.excludesFile",
@@ -651,18 +693,7 @@ async function filesOpenForWriting(dir: string, forceFailure: boolean): Promise<
651
693
  // detached or were reparented; unlike lsof +D, it doesn't walk every file in a large repository.
652
694
  const result = await $`lsof -n -P -F an`.nothrow().quiet();
653
695
  if (result.exitCode !== 0 || result.stderr.length > 0) return null;
654
- const output = result.stdout.toString();
655
-
656
- const files = new Set<string>();
657
- let access = "";
658
- for (const line of output.split("\n")) {
659
- if (line.startsWith("f")) access = "";
660
- if (line.startsWith("a")) access = line.slice(1);
661
- if (line.startsWith(`n${dir}/`) && (access === "w" || access === "u")) {
662
- files.add(path.relative(dir, line.slice(1)));
663
- }
664
- }
665
- return [...files];
696
+ return parseOpenWriters(result.stdout.toString(), dir);
666
697
  }
667
698
 
668
699
  // ── Finding, describing and applying changes ──────────────────────────────────────
@@ -957,7 +988,6 @@ async function cleanupWarnings(items: PreparedChange[], failCleanup = false): Pr
957
988
  } catch (error) {
958
989
  const detail = error instanceof Error ? error.message : String(error);
959
990
  const warning = `Changes reached their reported state, but transaction backup cleanup failed: ${detail}`;
960
- log("cleanup warning", { warning });
961
991
  return [warning];
962
992
  }
963
993
  }
@@ -1079,11 +1109,5 @@ if (import.meta.main) {
1079
1109
  const abort = new AbortController();
1080
1110
  process.on("SIGTERM", () => abort.abort());
1081
1111
  const options: RunOptions = await Bun.stdin.json();
1082
- RUN_ID = options.runId;
1083
- try {
1084
- console.log(JSON.stringify(await run(options, abort.signal)));
1085
- } catch (error) {
1086
- log("failed", { error: String(error) });
1087
- throw error;
1088
- }
1112
+ console.log(JSON.stringify(await run(options, abort.signal)));
1089
1113
  }
@@ -159,10 +159,15 @@ belong to `code`, not necessarily ordinary shell calls. `node:fs` and ordinary B
159
159
 
160
160
  Programs run in an isolated repository workspace. Use relative paths: the live checkout's absolute
161
161
  path is inaccessible on macOS. On both platforms, host paths outside the workspace are read-only
162
- (including external symlink targets), apart from run-history logging. `$TMPDIR` is private to the run.
162
+ (including external symlink targets). `$TMPDIR` is private to the run.
163
163
  Writes to `.git` are blocked.
164
164
 
165
165
  The default timeout is two seconds; request more for longer transformations.
166
- By default a failed program applies nothing. `rollback: "file"` can retain closed files after a
167
- timeout if writer inspection succeeds; exceptions, crashes or inspection failures apply nothing.
168
- Use that mode only when each retained file stands on its own.
166
+ By default, rollback happens per file: files involved in failed or interrupted edits are discarded,
167
+ while the others are retained, including after exceptions. Unattributed failures
168
+ (such as failed checks) preserve completed edits and report the failure. A failed file loses all
169
+ its edits from this run, even if the error is caught. Multi-file operations such as moves and a
170
+ single Grit invocation share one outcome. Arbitrary shell failures cannot identify failed closed
171
+ files. Open writers are inspected before failure exits and timeout termination; inspection failures
172
+ or crashes that bypass exit handling retain nothing. Cancellation still discards everything.
173
+ Use `rollback: "all"` when the whole change must be atomic.
package/history.ts DELETED
@@ -1,252 +0,0 @@
1
- import {
2
- chmodSync,
3
- closeSync,
4
- constants,
5
- fchmodSync,
6
- fstatSync,
7
- lstatSync,
8
- mkdirSync,
9
- openSync,
10
- readSync,
11
- renameSync,
12
- rmSync,
13
- type Stats,
14
- writeSync,
15
- } from "node:fs";
16
- import { homedir } from "node:os";
17
- import * as path from "node:path";
18
-
19
- export const RUN_HISTORY_FILE = path.join(homedir(), ".cache", "pi-shorthand", "runs.jsonl");
20
- export const RUN_HISTORY_LOCK_DIR = `${RUN_HISTORY_FILE}.locks`;
21
- export const RUN_HISTORY_MAX_BYTES = 1024 * 1024;
22
- export const RUN_HISTORY_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
23
- const HISTORY_EVENTS = new Set([
24
- "started",
25
- "overlay opened",
26
- "program started",
27
- "program exited",
28
- "finished",
29
- "command",
30
- "helper",
31
- "cleanup warning",
32
- "failed",
33
- ]);
34
-
35
- interface HistoryOptions {
36
- maxBytes?: number;
37
- maxAgeMs?: number;
38
- now?: number;
39
- env?: NodeJS.ProcessEnv;
40
- }
41
-
42
- export function historyEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
43
- return !["0", "false", "off"].includes((env.PI_SHORTHAND_HISTORY ?? "").toLowerCase());
44
- }
45
-
46
- /** Persist a bounded event containing only the allowlisted, non-source fields used for diagnostics. */
47
- export function appendRunHistory(
48
- event: string,
49
- details: Record<string, unknown> = {},
50
- file = RUN_HISTORY_FILE,
51
- options: HistoryOptions = {},
52
- ) {
53
- if (!historyEnabled(options.env)) return;
54
- if (!HISTORY_EVENTS.has(event)) return;
55
- const record = JSON.stringify({
56
- time: new Date(options.now ?? Date.now()).toISOString(),
57
- event,
58
- ...safeDetails(event, details),
59
- });
60
- const env = options.env ?? process.env;
61
- const sandboxed = env.PI_SHORTHAND_HISTORY_SANDBOX === "1";
62
- withHistoryLock(file, sandboxed, () => appendBounded(file, `${record}\n`, options, sandboxed));
63
- }
64
-
65
- const lockWait = new Int32Array(new SharedArrayBuffer(4));
66
-
67
- function withHistoryLock(file: string, sandboxed: boolean, run: () => void) {
68
- const directory = `${file}.locks`;
69
- if (!sandboxed) mkdirSync(directory, { recursive: true, mode: 0o700 });
70
- const directoryStats = lstatSync(directory);
71
- if (
72
- !directoryStats.isDirectory() ||
73
- directoryStats.isSymbolicLink() ||
74
- (process.getuid && directoryStats.uid !== process.getuid())
75
- ) {
76
- throw new Error(`Unsafe run history lock directory: ${directory}`);
77
- }
78
- if (!sandboxed) chmodSync(directory, 0o700);
79
-
80
- const lock = path.join(directory, "append");
81
- let acquired = false;
82
- for (let attempt = 0; attempt < 200; attempt++) {
83
- try {
84
- mkdirSync(lock, { mode: 0o700 });
85
- acquired = true;
86
- break;
87
- } catch (error) {
88
- if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
89
- const stats = lstatSync(lock, { throwIfNoEntry: false });
90
- if (stats && Date.now() - stats.mtimeMs > 10_000) {
91
- rmSync(lock, { recursive: true, force: true });
92
- continue;
93
- }
94
- Atomics.wait(lockWait, 0, 0, 5);
95
- }
96
- }
97
- if (!acquired) return; // history must never hold up the transaction itself
98
- try {
99
- run();
100
- } finally {
101
- rmSync(lock, { recursive: true, force: true });
102
- }
103
- }
104
-
105
- function safeDetails(event: string, details: Record<string, unknown>): Record<string, unknown> {
106
- const number = (key: string) => (typeof details[key] === "number" ? details[key] : undefined);
107
- const boolean = (key: string) => (typeof details[key] === "boolean" ? details[key] : undefined);
108
- const text = (key: string, allowed: readonly string[]) =>
109
- typeof details[key] === "string" && allowed.includes(details[key]) ? details[key] : undefined;
110
-
111
- switch (event) {
112
- case "started":
113
- return compact({
114
- run: safeIdentifier(details.run),
115
- timeoutMs: number("timeoutMs"),
116
- rollback: text("rollback", ["all", "file"]),
117
- });
118
- case "program started":
119
- return compact({ run: safeIdentifier(details.run), timeoutMs: number("timeoutMs") });
120
- case "program exited":
121
- return compact({
122
- run: safeIdentifier(details.run),
123
- exitCode: details.exitCode === null ? null : number("exitCode"),
124
- timedOut: boolean("timedOut"),
125
- aborted: boolean("aborted"),
126
- stillRunning: number("stillRunning"),
127
- });
128
- case "finished":
129
- return compact({
130
- run: safeIdentifier(details.run),
131
- changed: number("changed"),
132
- applied: number("applied"),
133
- conflicts: number("conflicts"),
134
- });
135
- case "command":
136
- return compact({ run: safeIdentifier(details.run), command: safeCommand(details.command) });
137
- case "helper":
138
- return compact({
139
- run: safeIdentifier(details.run),
140
- helper: text("helper", [
141
- "glob",
142
- "grep",
143
- "sg.find",
144
- "sg.one",
145
- "sg.file",
146
- "sg.insert",
147
- "sg.move",
148
- "sg.remove",
149
- "sg.rewrite",
150
- "grit",
151
- ]),
152
- ms: number("ms"),
153
- results: number("results"),
154
- });
155
- default:
156
- return compact({ run: safeIdentifier(details.run) });
157
- }
158
- }
159
-
160
- function compact(value: Record<string, unknown>): Record<string, unknown> {
161
- return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
162
- }
163
-
164
- function safeIdentifier(value: unknown): string | undefined {
165
- return typeof value === "string" && /^[a-zA-Z0-9_-]{1,64}$/.test(value) ? value : undefined;
166
- }
167
-
168
- function safeCommand(value: unknown): string | undefined {
169
- if (typeof value !== "string") return undefined;
170
- const command = path.basename(value);
171
- return /^[a-zA-Z0-9_.-]{1,64}$/.test(command) ? command : undefined;
172
- }
173
-
174
- function appendBounded(file: string, line: string, options: HistoryOptions, sandboxed: boolean) {
175
- const maxBytes = options.maxBytes ?? RUN_HISTORY_MAX_BYTES;
176
- const maxAgeMs = options.maxAgeMs ?? RUN_HISTORY_MAX_AGE_MS;
177
- const now = options.now ?? Date.now();
178
- const directory = path.dirname(file);
179
- const rotated = `${file}.1`;
180
- if (!sandboxed) {
181
- mkdirSync(directory, { recursive: true, mode: 0o700 });
182
- const directoryStats = lstatSync(directory);
183
- if (
184
- !directoryStats.isDirectory() ||
185
- directoryStats.isSymbolicLink() ||
186
- (process.getuid && directoryStats.uid !== process.getuid())
187
- ) {
188
- throw new Error(`Unsafe run history directory: ${directory}`);
189
- }
190
- chmodSync(directory, 0o700);
191
- }
192
-
193
- let existing: Stats | undefined;
194
- try {
195
- existing = lstatSync(file) as Stats;
196
- } catch (error) {
197
- if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
198
- }
199
- if (existing?.isSymbolicLink() || (existing && !existing.isFile()))
200
- throw new Error(`Unsafe run history file: ${file}`);
201
- if (!sandboxed) {
202
- const rotatedStats = lstatSync(rotated, { throwIfNoEntry: false });
203
- if (rotatedStats?.isSymbolicLink() || (rotatedStats && !rotatedStats.isFile())) {
204
- rmSync(rotated, { recursive: true, force: true });
205
- } else if (rotatedStats && now - cohortStartedAt(rotated, rotatedStats) > maxAgeMs) {
206
- rmSync(rotated, { force: true });
207
- }
208
- }
209
- if (!sandboxed && existing && now - cohortStartedAt(file, existing) > maxAgeMs) {
210
- rmSync(file, { force: true });
211
- existing = undefined;
212
- } else if (!sandboxed && existing && existing.size + Buffer.byteLength(line) > maxBytes) {
213
- rmSync(rotated, { force: true });
214
- renameSync(file, rotated);
215
- existing = undefined;
216
- }
217
-
218
- const descriptor = openSync(
219
- file,
220
- constants.O_WRONLY | constants.O_CREAT | constants.O_APPEND | constants.O_NOFOLLOW,
221
- 0o600,
222
- );
223
- try {
224
- const stats = fstatSync(descriptor);
225
- if (!stats.isFile() || (process.getuid && stats.uid !== process.getuid()))
226
- throw new Error(`Unsafe run history file: ${file}`);
227
- if (sandboxed) {
228
- if ((stats.mode & 0o077) !== 0) throw new Error(`Unsafe run history permissions: ${file}`);
229
- } else {
230
- fchmodSync(descriptor, 0o600);
231
- }
232
- if (stats.size + Buffer.byteLength(line) <= maxBytes) writeSync(descriptor, line);
233
- } finally {
234
- closeSync(descriptor);
235
- }
236
- }
237
-
238
- /** The first record starts a cohort; later appends cannot extend that cohort's retention deadline. */
239
- function cohortStartedAt(file: string, stats: Stats): number {
240
- const descriptor = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
241
- try {
242
- const buffer = Buffer.alloc(2048);
243
- const length = readSync(descriptor, buffer, 0, buffer.length, 0);
244
- const firstLine = buffer.subarray(0, length).toString("utf8").split("\n", 1)[0];
245
- const time = Date.parse(JSON.parse(firstLine).time);
246
- return Number.isFinite(time) ? time : stats.birthtimeMs;
247
- } catch {
248
- return stats.birthtimeMs;
249
- } finally {
250
- closeSync(descriptor);
251
- }
252
- }