pi-shorthand 0.3.1 → 0.4.0

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,41 @@
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
14
+ ```
15
+
16
+ On macOS, install clang and AgentFS:
17
+
18
+ ```sh
19
+ xcode-select --install
20
+ curl -fsSL https://agentfs.ai/install | bash
16
21
  ```
17
22
 
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`.
23
+ On Linux, install bubblewrap 0.9 or later. For Debian and Ubuntu:
19
24
 
20
- A project install (`pi install -l`) only loads once you trust the project: Pi asks, or run `pi --approve`.
25
+ ```sh
26
+ sudo apt install bubblewrap
27
+ ```
21
28
 
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`).
29
+ ## Technical choices
25
30
 
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.
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.
29
32
 
30
- ## What a program can use
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.
31
34
 
32
- Anything in Bun or Node, plus these globals (no imports):
35
+ ## Diagnosing slow calls
33
36
 
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");
45
- ```
37
+ Calls taking longer than their configured program timeout (two seconds by default) show a muted timing footer. The timeout limits the editing program, not the entire transaction. Live progress also names the active setup or cleanup step.
38
+
39
+ The footer separates parent-observed startup/IPC, runner work, and response/exit overhead. Snapshot detail includes inventory, copy, verification, attempt count, entry count, and logical file bytes. Nested measurements overlap and must not be added to their containing phase totals; logical bytes are not measured disk I/O. Formatting and edit execution include separate subprocess-wait and cleanup measurements.
46
40
 
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.
51
-
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
89
-
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.
92
-
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.
95
-
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.
41
+ Infrastructure failures retain completed measurements and identify the failed phase. If the runner exits before reporting completion, its execution interval is marked as observed/incomplete. Structured diagnostics are attached to tool result details; source contents and individual filenames are not recorded in diagnostic events.
package/api.d.ts CHANGED
@@ -8,6 +8,7 @@ declare global {
8
8
  const grep: ShorthandGlobals["grep"];
9
9
  const sg: ShorthandGlobals["sg"];
10
10
  const grit: ShorthandGlobals["grit"];
11
+ const ts: ShorthandGlobals["ts"];
11
12
  }
12
13
 
13
14
  export type { RewriteResult } from "./prelude.ts";
package/diagnostics.ts ADDED
@@ -0,0 +1,142 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ export interface DiagnosticSpan {
4
+ name: string;
5
+ startMs: number;
6
+ durationMs?: number;
7
+ failed?: boolean;
8
+ }
9
+
10
+ export interface Diagnostics {
11
+ spans: DiagnosticSpan[];
12
+ counters: Record<string, number>;
13
+ phase?: string;
14
+ failurePhase?: string;
15
+ runnerMs?: number;
16
+ startupMs?: number;
17
+ responseMs?: number;
18
+ wallMs?: number;
19
+ incomplete?: boolean;
20
+ }
21
+
22
+ const context = new AsyncLocalStorage<{
23
+ startedAt: number;
24
+ diagnostics: Diagnostics;
25
+ publish: (diagnostics: Diagnostics) => void;
26
+ phaseSpan?: DiagnosticSpan;
27
+ }>();
28
+
29
+ /** Run-local, bounded metadata only: never record source text, filenames or command output. */
30
+ export async function withDiagnostics<T>(
31
+ operation: () => Promise<T>,
32
+ publish: (diagnostics: Diagnostics) => void,
33
+ ): Promise<{ value: T; diagnostics: Diagnostics }> {
34
+ const state = {
35
+ startedAt: performance.now(),
36
+ diagnostics: { spans: [], counters: {} } as Diagnostics,
37
+ publish: (diagnostics: Diagnostics) => {
38
+ try {
39
+ publish(diagnostics);
40
+ } catch {
41
+ /* Diagnostics must not change the operation's outcome. */
42
+ }
43
+ },
44
+ };
45
+ return context.run(state, async () => {
46
+ state.publish(state.diagnostics);
47
+ try {
48
+ return { value: await operation(), diagnostics: state.diagnostics };
49
+ } catch (error) {
50
+ diagnosticFailure();
51
+ throw error;
52
+ } finally {
53
+ const active = context.getStore()!;
54
+ if (active.phaseSpan)
55
+ active.phaseSpan.durationMs = Math.round(performance.now() - state.startedAt - active.phaseSpan.startMs);
56
+ state.diagnostics.runnerMs = Math.round(performance.now() - state.startedAt);
57
+ state.publish(state.diagnostics);
58
+ }
59
+ });
60
+ }
61
+
62
+ export async function measure<T>(name: string, operation: () => Promise<T>): Promise<T> {
63
+ const state = context.getStore();
64
+ if (!state) return operation();
65
+ const start = performance.now();
66
+ const span: DiagnosticSpan = { name, startMs: Math.round(start - state.startedAt) };
67
+ state.diagnostics.spans.push(span);
68
+ state.publish(state.diagnostics);
69
+ try {
70
+ return await operation();
71
+ } catch (error) {
72
+ span.failed = true;
73
+ throw error;
74
+ } finally {
75
+ span.durationMs = Math.round(performance.now() - start);
76
+ state.publish(state.diagnostics);
77
+ }
78
+ }
79
+
80
+ export function diagnosticCounter(name: string, value: number, add = false) {
81
+ const state = context.getStore();
82
+ if (!state) return;
83
+ state.diagnostics.counters[name] = value + (add ? (state.diagnostics.counters[name] ?? 0) : 0);
84
+ state.publish(state.diagnostics);
85
+ }
86
+
87
+ export function diagnosticPhase(phase: string) {
88
+ const state = context.getStore();
89
+ if (!state) return;
90
+ const now = Math.round(performance.now() - state.startedAt);
91
+ if (state.phaseSpan) state.phaseSpan.durationMs = now - state.phaseSpan.startMs;
92
+ state.phaseSpan = { name: phase, startMs: now };
93
+ state.diagnostics.spans.push(state.phaseSpan);
94
+ state.diagnostics.phase = phase;
95
+ state.publish(state.diagnostics);
96
+ }
97
+
98
+ export function diagnosticFailure() {
99
+ const state = context.getStore();
100
+ if (!state || state.diagnostics.failurePhase) return;
101
+ state.diagnostics.failurePhase = state.diagnostics.phase;
102
+ if (state.phaseSpan) state.phaseSpan.failed = true;
103
+ }
104
+
105
+ /** Spans are nested within runner phases; these figures must not be added to the phase total. */
106
+ const duration = (ms: number) => `${ms}ms`;
107
+
108
+ /** Parent-observed startup includes event delivery; a missing final event means execution is incomplete. */
109
+ export function completeDiagnostics(diagnostics: Diagnostics, wallMs: number, startupMs: number): Diagnostics {
110
+ diagnostics.wallMs = Math.round(wallMs);
111
+ diagnostics.startupMs = Math.round(startupMs);
112
+ if (diagnostics.runnerMs === undefined) {
113
+ diagnostics.incomplete = true;
114
+ diagnostics.runnerMs = Math.max(0, diagnostics.wallMs - diagnostics.startupMs);
115
+ }
116
+ diagnostics.responseMs = Math.max(0, diagnostics.wallMs - diagnostics.startupMs - diagnostics.runnerMs);
117
+ return diagnostics;
118
+ }
119
+
120
+ export function diagnosticLines(diagnostics: Diagnostics): string[] {
121
+ const lines: string[] = [];
122
+ if (diagnostics.wallMs !== undefined) {
123
+ lines.push(
124
+ `wall ${duration(diagnostics.wallMs)} · runner startup/IPC ${duration(diagnostics.startupMs ?? 0)} · runner${diagnostics.incomplete ? " (observed, incomplete)" : ""} ${duration(diagnostics.runnerMs ?? 0)} · response/exit ${duration(diagnostics.responseMs ?? 0)}`,
125
+ );
126
+ }
127
+ if (diagnostics.spans.length) {
128
+ lines.push("phase detail (nested measurements overlap):");
129
+ lines.push(
130
+ ...diagnostics.spans
131
+ .filter((span) => span.durationMs !== 0)
132
+ .map(
133
+ (span) =>
134
+ ` ${span.name}: ${span.durationMs === undefined ? "incomplete" : duration(span.durationMs)}${span.failed ? " (failed)" : ""}`,
135
+ ),
136
+ );
137
+ }
138
+ const counters = Object.entries(diagnostics.counters);
139
+ if (counters.length) lines.push(counters.map(([name, value]) => `${name}: ${value}`).join(" · "));
140
+ if (diagnostics.failurePhase) lines.push(`failed during: ${diagnostics.failurePhase}`);
141
+ return lines;
142
+ }
package/display.ts CHANGED
@@ -8,7 +8,8 @@
8
8
  */
9
9
 
10
10
  import { getLanguageFromPath, highlightCode, keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
11
- import type { FileChange, RunResult } from "./runner.ts";
11
+ import type { FileChange, RunResult, RunTimings } from "./runner.ts";
12
+ import { diagnosticLines } from "./diagnostics.ts";
12
13
 
13
14
  type ToolBackground = "toolSuccessBg" | "toolErrorBg";
14
15
 
@@ -21,7 +22,7 @@ const LISTED_FILES = 8; // …showing this many, then "and N more files"
21
22
  const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
22
23
 
23
24
  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`];
25
+ const settings = [args.rollback === "all" && "rollback all", args.timeout && `program timeout ${args.timeout}s`];
25
26
  const suffix = settings.filter(Boolean).join(", ");
26
27
  return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
27
28
  }
@@ -69,29 +70,65 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
69
70
  }
70
71
  for (const file of run.rolledBack) {
71
72
  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";
73
+ ? "open writers could not be inspected"
74
+ : "file edit failed or was interrupted";
76
75
  lines.push(indent(theme.fg("warning", `rolled back ${file}: ${reason}`)));
77
76
  }
78
77
  for (const warning of [...run.warnings, ...printedWarnings]) lines.push(indent(theme.fg("warning", `⚠ ${warning}`)));
79
78
 
80
79
  // The sections, each after a blank line.
81
80
  const sections: string[][] = [];
82
- const background: ToolBackground = run.exitCode === 0 && run.conflicts.length === 0 ? "toolSuccessBg" : "toolErrorBg";
81
+ const background: ToolBackground =
82
+ run.exitCode === 0 && run.conflicts.length === 0 && run.rolledBack.length === 0 ? "toolSuccessBg" : "toolErrorBg";
83
83
  if (applied.length > 0) sections.push(diffLines(applied, expanded, theme, background));
84
84
  if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
85
85
  if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
86
86
  for (const section of sections) lines.push("", ...section);
87
+ const timing = timingBreakdown(run);
88
+ if (timing) lines.push("", theme.fg("muted", `timing: ${timing}`));
89
+ if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run.timeoutMs || run.exitCode !== 0)) {
90
+ lines.push(...diagnosticLines(run.diagnostics).map((line) => theme.fg("muted", line)));
91
+ }
87
92
  return lines;
88
93
  }
89
94
 
95
+ const TIMING_LABELS: Record<keyof RunTimings, string> = {
96
+ resolveRepositoryMs: "repository",
97
+ waitForLockMs: "lock wait",
98
+ workspaceSetupMs: "workspace setup",
99
+ programMs: "program",
100
+ scanChangesMs: "change scan",
101
+ formatMs: "formatter",
102
+ workspaceCloseMs: "workspace cleanup",
103
+ checkConflictsMs: "conflict check",
104
+ applyMs: "apply",
105
+ renderDiffMs: "diff",
106
+ unattributedMs: "other",
107
+ };
108
+
109
+ /** Explain calls exceeding the configured program budget, even when no individual phase does. */
110
+ export function timingBreakdown(run: RunResult): string | undefined {
111
+ if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < run.timeoutMs) return undefined;
112
+ const significant = Object.entries(run.timings)
113
+ .map(([phase, milliseconds]) => ({
114
+ label: TIMING_LABELS[phase as keyof RunTimings],
115
+ milliseconds,
116
+ }))
117
+ .filter(({ milliseconds }) => milliseconds > 0)
118
+ .toSorted((a, b) => b.milliseconds - a.milliseconds);
119
+ if (significant.length === 0) return undefined;
120
+ return significant.map(({ label, milliseconds }) => `${label} ${formatDuration(milliseconds)}`).join(" · ");
121
+ }
122
+
123
+ function formatDuration(milliseconds: number): string {
124
+ return milliseconds < 1_000 ? `${milliseconds}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
125
+ }
126
+
90
127
  /** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
91
128
  function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
92
129
  const muted = (text: string) => theme.fg("muted", text);
93
130
  const took = muted(` · ${(run.durationMs / 1000).toFixed(1)}s`);
94
- const failure = run.timedOut ? `Timed out after ${run.timeoutMs / 1000}s` : "Failed";
131
+ const failure = run.timedOut ? `Program timed out after ${run.timeoutMs / 1000}s` : "Failed";
95
132
  const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
96
133
 
97
134
  if (run.conflicts.length > 0) {
@@ -104,7 +141,7 @@ function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
104
141
  return theme.fg("error", `✕ Conflict · ${application}${program}`) + took;
105
142
  }
106
143
  if (run.exitCode === 0 && run.changes.length === 0) return theme.fg("success", "✓ No changes") + took;
107
- if (run.exitCode === 0) {
144
+ if (run.exitCode === 0 && run.rolledBack.length === 0) {
108
145
  return theme.fg("success", `✓ Applied ${fileCount(applied)}`) + ` · ${stats(applied, theme)}` + took;
109
146
  }
110
147
  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/format.ts CHANGED
@@ -4,6 +4,14 @@ import { dirname, extname, join, relative, resolve } from "node:path";
4
4
 
5
5
  type Command = { name: string; executable: string; args: string[]; cwd: string };
6
6
 
7
+ /** A conservative filter: configuration and executable discovery still happen inside the sandbox. */
8
+ export function supportsFormatting(file: string): boolean {
9
+ return (
10
+ /\.(?:[cm]?[jt]sx?|jsonc?|css|scss|less|html|vue|svelte|mdx?|ya?ml|graphql)$/i.test(file) ||
11
+ [".py", ".pyi", ".go", ".rs"].includes(extname(file))
12
+ );
13
+ }
14
+
7
15
  function text(file: string): string {
8
16
  try {
9
17
  return readFileSync(file, "utf8");