pi-shorthand 0.1.0 → 0.2.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,11 +1,13 @@
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)
4
+
3
5
  A [Pi](https://github.com/earendil-works/pi) tool for token-efficient writes. The model writes a whole
4
6
  change as one small Bun program, in shorthand, instead of calling `read`, `edit` and `bash` over
5
7
  and over: fewer tokens, and fewer round trips.
6
8
 
7
- The program sees your repo as normal, but its writes are held back. If it succeeds, they're applied
8
- and the model gets the diff. If it fails, nothing changes.
9
+ The program sees your repo as normal, but its writes are held back. By default, they're applied only
10
+ if the program succeeds and destination files are unchanged, and the model gets the diff.
9
11
 
10
12
  ## Install
11
13
 
@@ -15,8 +17,10 @@ pi install npm:pi-shorthand
15
17
 
16
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`.
17
19
 
20
+ A project install (`pi install -l`) only loads once you trust the project: Pi asks, or run `pi --approve`.
21
+
18
22
  You also need Bun, git, and either [bubblewrap](https://github.com/containers/bubblewrap) 0.9+
19
- (Linux) or [AgentFS](https://github.com/tursodatabase/agentfs) (macOS:
23
+ (Linux) or [AgentFS](https://github.com/tursodatabase/agentfs) and `clang` (macOS:
20
24
  `curl -fsSL https://agentfs.ai/install | bash`).
21
25
 
22
26
  ## What a program can use
@@ -29,23 +33,37 @@ glob("src/**/*.ts"); // → ["src/a.ts", …]
29
33
  grep("oldApi(", "src"); // → [{ file, line, text }, …]
30
34
  sg.find("oldApi($$$ARGS)", "src"); // ast-grep search
31
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"));
32
39
  sg.parse(sg.Lang.TypeScript, source); // ast-grep's own API (or import from "@ast-grep/napi")
33
40
  grit("`console.log($x)` => `logger.info($x)`", "src");
34
41
  ```
35
42
 
36
43
  ## Options
37
44
 
38
- - `rollback`: `"all"` (default) applies nothing if the program fails. `"file"` keeps the files it
39
- finished writing.
45
+ - `title`: a short description shown with the call (required).
46
+ - `rollback`: `"all"` (default) applies nothing if the program fails. After a timeout, `"file"`
47
+ keeps changed files that were no longer open for writing if writer inspection succeeds. Other
48
+ failures apply nothing because open writers cannot be identified after the process exits.
40
49
  - `timeout`: seconds before the program is killed. Default 2.
41
50
 
42
51
  ## Good to know
43
52
 
44
53
  - Only files git tracks, or would track, are applied.
45
- - On macOS your repo is briefly swapped for the overlay while a program runs (about 150 ms), so your
46
- editor may notice. On Linux nothing outside the program sees it (about 15 ms).
54
+ - Each run snapshots the checkout first; reflinks make that cheap where supported, while other
55
+ filesystems copy its contents and use corresponding temporary space. On macOS the program runs at
56
+ a private AgentFS mount, so use paths relative to its working directory for repository files.
57
+ - Runs against the same checkout are serialized. If another process edits a destination while a run
58
+ is in progress, shorthand checks it again immediately before replacing it and reports a conflict.
59
+ A non-cooperating writer can still race the final filesystem rename or removal itself.
47
60
  - To try it with only `read` and `code`: `pi --tools read,code`.
48
- - To watch runs as they happen, including each command a program starts: `tail -f ~/.cache/pi-shorthand/runs.jsonl`.
61
+ - Run history is stored in `~/.cache/pi-shorthand/runs.jsonl` with directory mode `0700` and file
62
+ mode `0600`. It records timestamps, opaque run IDs, lifecycle events, exit status, durations,
63
+ counts, helper names, and shell executable names. It does not record programs, output, errors,
64
+ arguments, repository paths, file paths, or diffs. The log rotates at 1 MiB and expires after
65
+ seven days. Set `PI_SHORTHAND_HISTORY=0` to disable it. To watch enabled history:
66
+ `tail -f ~/.cache/pi-shorthand/runs.jsonl`.
49
67
 
50
68
  ## Developing
51
69
 
package/display.ts CHANGED
@@ -7,9 +7,14 @@
7
7
  * on failure the output, then what would have changed; for an exploration, just the output.
8
8
  */
9
9
 
10
- import { keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
10
+ import { getLanguageFromPath, highlightCode, keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
11
11
  import type { FileChange, RunResult } from "./runner.ts";
12
12
 
13
+ type ToolBackground = "toolSuccessBg" | "toolErrorBg";
14
+
15
+ const ANSI_CUBE_VALUES = [0, 95, 135, 175, 215, 255];
16
+ const ANSI_GRAY_VALUES = Array.from({ length: 24 }, (_, index) => 8 + index * 10);
17
+
13
18
  const OUTPUT_PREVIEW_LINES = 5; // like Pi's bash tool
14
19
  const INLINE_DIFF_LINES = 40; // a longer diff collapses to a list of its files…
15
20
  const LISTED_FILES = 8; // …showing this many, then "and N more files"
@@ -21,6 +26,19 @@ export function callLine(args: { title?: string; timeout?: number; rollback?: st
21
26
  return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
22
27
  }
23
28
 
29
+ /** Text to show when a completed tool result has no structured RunResult details. */
30
+ export function unstructuredResultText(content: readonly unknown[]): string {
31
+ const text = content
32
+ .flatMap((block) =>
33
+ typeof block === "object" && block !== null && "type" in block && block.type === "text" && "text" in block
34
+ ? [String(block.text)]
35
+ : [],
36
+ )
37
+ .join("\n")
38
+ .trim();
39
+ return text || "Code failed without result details";
40
+ }
41
+
24
42
  /** Lines that belong to the verdict line above them. */
25
43
  function indent(line: string): string {
26
44
  return ` ${line}`;
@@ -43,21 +61,28 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
43
61
  const error = run.exitCode !== 0 && !run.timedOut ? errorMessage(run.output) : undefined;
44
62
  if (error) lines.push(indent(theme.fg("error", error)));
45
63
  if (error && run.errorLine) lines.push(indent(theme.fg("muted", shorten(run.errorLine, 88))));
64
+ for (const file of run.conflicts) lines.push(indent(theme.fg("error", `changed while running: ${file}`)));
46
65
  // A command still running is what it was stuck on; otherwise the last step it logged is a clue.
47
66
  for (const command of run.stillRunning) lines.push(indent(theme.fg("warning", `stuck on $ ${command}`)));
48
67
  if (run.timedOut && run.stillRunning.length === 0 && run.lastStep) {
49
68
  lines.push(indent(theme.fg("warning", `last step: ${run.lastStep}`)));
50
69
  }
51
70
  for (const file of run.rolledBack) {
52
- lines.push(indent(theme.fg("warning", `rolled back ${file}: half-written when the program was killed`)));
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";
76
+ lines.push(indent(theme.fg("warning", `rolled back ${file}: ${reason}`)));
53
77
  }
54
78
  for (const warning of [...run.warnings, ...printedWarnings]) lines.push(indent(theme.fg("warning", `⚠ ${warning}`)));
55
79
 
56
80
  // The sections, each after a blank line.
57
81
  const sections: string[][] = [];
58
- if (applied.length > 0) sections.push(diffLines(applied, expanded, theme));
82
+ const background: ToolBackground = run.exitCode === 0 && run.conflicts.length === 0 ? "toolSuccessBg" : "toolErrorBg";
83
+ if (applied.length > 0) sections.push(diffLines(applied, expanded, theme, background));
59
84
  if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
60
- if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme));
85
+ if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
61
86
  for (const section of sections) lines.push("", ...section);
62
87
  return lines;
63
88
  }
@@ -69,6 +94,15 @@ function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
69
94
  const failure = run.timedOut ? `Timed out after ${run.timeoutMs / 1000}s` : "Failed";
70
95
  const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
71
96
 
97
+ if (run.conflicts.length > 0) {
98
+ const program = run.timedOut
99
+ ? ` · timed out after ${run.timeoutMs / 1000}s`
100
+ : run.exitCode
101
+ ? ` · exit ${run.exitCode}`
102
+ : "";
103
+ const application = applied.length === 0 ? "nothing applied" : `${fileCount(applied)} applied`;
104
+ return theme.fg("error", `✕ Conflict · ${application}${program}`) + took;
105
+ }
72
106
  if (run.exitCode === 0 && run.changes.length === 0) return theme.fg("success", "✓ No changes") + took;
73
107
  if (run.exitCode === 0) {
74
108
  return theme.fg("success", `✓ Applied ${fileCount(applied)}`) + ` · ${stats(applied, theme)}` + took;
@@ -104,8 +138,8 @@ function outputLines(output: string, expanded: boolean, labelled: boolean, theme
104
138
  }
105
139
 
106
140
  /** Each file's diff under its name, in Pi's own diff style. A long diff collapses to a list of files. */
107
- function diffLines(changes: FileChange[], expanded: boolean, theme: Theme): string[] {
108
- const files = changes.map((change) => [fileLine(change, theme), ...renderDiff(toPiDiff(change.patch)).split("\n")]);
141
+ function diffLines(changes: FileChange[], expanded: boolean, theme: Theme, background: ToolBackground): string[] {
142
+ const files = changes.map((change) => [fileLine(change, theme), ...renderFileDiff(change, theme, background)]);
109
143
  const total = files.reduce((sum, file) => sum + file.length, 0);
110
144
  if (!expanded && total > INLINE_DIFF_LINES) {
111
145
  return [...fileList(changes, theme), theme.fg("muted", `(${keyHint("app.tools.expand", "to see the diff")})`)];
@@ -117,9 +151,152 @@ function diffLines(changes: FileChange[], expanded: boolean, theme: Theme): stri
117
151
  return [...lines.slice(0, EXPANDED_DIFF_LINES), theme.fg("muted", `… ${more} more lines of diff`)];
118
152
  }
119
153
 
120
- function notAppliedLines(changes: FileChange[], expanded: boolean, theme: Theme): string[] {
154
+ function renderFileDiff(change: FileChange, theme: Theme, background: ToolBackground): string[] {
155
+ const diff = toPiDiff(change.patch);
156
+ const language = getLanguageFromPath(change.path);
157
+ if (!language || diff === " binary file changed") return renderDiff(diff).split("\n");
158
+
159
+ const rendered: string[] = [];
160
+ let hunk: string[] = [];
161
+ const flush = () => {
162
+ if (hunk.length > 0) rendered.push(...renderSyntaxHunk(hunk, language, theme, background));
163
+ hunk = [];
164
+ };
165
+ for (const line of diff.split("\n")) {
166
+ if (/^\s+\.\.\.$/.test(line)) {
167
+ flush();
168
+ rendered.push(theme.fg("toolDiffContext", line));
169
+ } else {
170
+ hunk.push(line);
171
+ }
172
+ }
173
+ flush();
174
+ return rendered;
175
+ }
176
+
177
+ function renderSyntaxHunk(lines: string[], language: string, theme: Theme, background: ToolBackground): string[] {
178
+ const parsed = lines.map((line) => {
179
+ const match = line.match(/^([+\- ])(\s*\d*) (.*)$/);
180
+ return match ? { prefix: match[1], number: match[2], code: match[3] } : undefined;
181
+ });
182
+ const oldLines = parsed.flatMap((line) => (line && line.prefix !== "+" ? [line.code] : []));
183
+ const newLines = parsed.flatMap((line) => (line && line.prefix !== "-" ? [line.code] : []));
184
+ const oldHighlighted = highlightCode(oldLines.join("\n"), language);
185
+ const newHighlighted = highlightCode(newLines.join("\n"), language);
186
+ let oldIndex = 0;
187
+ let newIndex = 0;
188
+
189
+ return parsed.map((line, index) => {
190
+ if (!line) return theme.fg("toolDiffContext", lines[index]);
191
+ if (line.prefix === "-") {
192
+ const content = theme.fg("toolDiffRemoved", `-${line.number} `) + oldHighlighted[oldIndex++];
193
+ return tintedDiffLine(content, "toolDiffRemoved", background, theme);
194
+ }
195
+ if (line.prefix === "+") {
196
+ const content = theme.fg("toolDiffAdded", `+${line.number} `) + newHighlighted[newIndex++];
197
+ return tintedDiffLine(content, "toolDiffAdded", background, theme);
198
+ }
199
+ oldIndex++;
200
+ return theme.fg("toolDiffContext", ` ${line.number} `) + newHighlighted[newIndex++];
201
+ });
202
+ }
203
+
204
+ function tintedDiffLine(
205
+ line: string,
206
+ changeColor: "toolDiffAdded" | "toolDiffRemoved",
207
+ baseBackground: ToolBackground,
208
+ theme: Theme,
209
+ ): string {
210
+ const base = ansiRgb(theme.getBgAnsi(baseBackground));
211
+ const change = ansiRgb(theme.getFgAnsi(changeColor));
212
+ if (!base || !change) return line;
213
+ const [red, green, blue] = base.map((channel, index) => Math.round(channel * 0.86 + change[index] * 0.14));
214
+ const background =
215
+ theme.getColorMode() === "truecolor"
216
+ ? `\x1b[48;2;${red};${green};${blue}m`
217
+ : `\x1b[48;5;${ansi256(red, green, blue)}m`;
218
+ return `${background}${line}${theme.getBgAnsi(baseBackground)}`;
219
+ }
220
+
221
+ function ansiRgb(code: string): [number, number, number] | undefined {
222
+ const truecolor = code.match(/\[(?:38|48);2;(\d+);(\d+);(\d+)m/);
223
+ if (truecolor) return [Number(truecolor[1]), Number(truecolor[2]), Number(truecolor[3])];
224
+ const indexed = code.match(/\[(?:38|48);5;(\d+)m/);
225
+ return indexed ? rgbFromAnsi256(Number(indexed[1])) : undefined;
226
+ }
227
+
228
+ function rgbFromAnsi256(index: number): [number, number, number] | undefined {
229
+ if (index < 0 || index > 255) return undefined;
230
+ if (index < 16) {
231
+ const palette = [
232
+ [0, 0, 0],
233
+ [128, 0, 0],
234
+ [0, 128, 0],
235
+ [128, 128, 0],
236
+ [0, 0, 128],
237
+ [128, 0, 128],
238
+ [0, 128, 128],
239
+ [192, 192, 192],
240
+ [128, 128, 128],
241
+ [255, 0, 0],
242
+ [0, 255, 0],
243
+ [255, 255, 0],
244
+ [0, 0, 255],
245
+ [255, 0, 255],
246
+ [0, 255, 255],
247
+ [255, 255, 255],
248
+ ] as const;
249
+ return [...palette[index]];
250
+ }
251
+ if (index >= 232) {
252
+ const gray = 8 + (index - 232) * 10;
253
+ return [gray, gray, gray];
254
+ }
255
+ const cube = index - 16;
256
+ return [
257
+ ansiCubeChannel(Math.floor(cube / 36)),
258
+ ansiCubeChannel(Math.floor((cube % 36) / 6)),
259
+ ansiCubeChannel(cube % 6),
260
+ ];
261
+ }
262
+
263
+ function ansiCubeChannel(value: number): number {
264
+ return value === 0 ? 0 : 55 + value * 40;
265
+ }
266
+
267
+ function ansi256(red: number, green: number, blue: number): number {
268
+ const [redIndex, greenIndex, blueIndex] = [red, green, blue].map((channel) =>
269
+ closestIndex(channel, ANSI_CUBE_VALUES),
270
+ );
271
+ const cube = [ANSI_CUBE_VALUES[redIndex], ANSI_CUBE_VALUES[greenIndex], ANSI_CUBE_VALUES[blueIndex]];
272
+ const cubeIndex = 16 + 36 * redIndex + 6 * greenIndex + blueIndex;
273
+ const cubeDistance = colorDistance([red, green, blue], cube);
274
+
275
+ const luminance = Math.round(0.299 * red + 0.587 * green + 0.114 * blue);
276
+ const grayOffset = closestIndex(luminance, ANSI_GRAY_VALUES);
277
+ const gray = ANSI_GRAY_VALUES[grayOffset];
278
+ const grayDistance = colorDistance([red, green, blue], [gray, gray, gray]);
279
+ const spread = Math.max(red, green, blue) - Math.min(red, green, blue);
280
+ return spread < 10 && grayDistance < cubeDistance ? 232 + grayOffset : cubeIndex;
281
+ }
282
+
283
+ function closestIndex(value: number, choices: number[]): number {
284
+ let closest = 0;
285
+ for (let index = 1; index < choices.length; index++) {
286
+ if (Math.abs(value - choices[index]) < Math.abs(value - choices[closest])) closest = index;
287
+ }
288
+ return closest;
289
+ }
290
+
291
+ function colorDistance(first: number[], second: number[]): number {
292
+ return (
293
+ (first[0] - second[0]) ** 2 * 0.299 + (first[1] - second[1]) ** 2 * 0.587 + (first[2] - second[2]) ** 2 * 0.114
294
+ );
295
+ }
296
+
297
+ function notAppliedLines(changes: FileChange[], expanded: boolean, theme: Theme, background: ToolBackground): string[] {
121
298
  const heading = theme.fg("muted", `Would have changed ${fileCount(changes)} · `) + stats(changes, theme);
122
- if (expanded) return [heading, "", ...diffLines(changes, true, theme)];
299
+ if (expanded) return [heading, "", ...diffLines(changes, true, theme, background)];
123
300
  return [heading + theme.fg("muted", ` (${keyHint("app.tools.expand", "to see the diff")})`)];
124
301
  }
125
302
 
@@ -133,7 +310,19 @@ function fileList(changes: FileChange[], theme: Theme): string[] {
133
310
  /** e.g. "src/a.ts +3 −1", or "src/new.ts (new) +12 −0" */
134
311
  function fileLine(change: FileChange, theme: Theme): string {
135
312
  const kind = change.kind === "modified" ? "" : theme.fg("muted", change.kind === "added" ? " (new)" : " (deleted)");
136
- return `${theme.fg("accent", change.path)}${kind} ${stats([change], theme)}`;
313
+ const metadata = fileMetadataSummary(change);
314
+ return `${theme.fg("accent", change.path)}${kind}${metadata ? theme.fg("muted", ` (${metadata})`) : ""} ${stats([change], theme)}`;
315
+ }
316
+
317
+ export function fileMetadataSummary(change: FileChange): string {
318
+ if (change.beforeType && change.afterType && change.beforeType !== change.afterType) {
319
+ return `${change.beforeType} → ${change.afterType}`;
320
+ }
321
+ if (change.afterType === "symlink" && change.beforeType !== "symlink") return "symlink";
322
+ if (change.beforeMode !== undefined && change.afterMode !== undefined && change.beforeMode !== change.afterMode) {
323
+ return `${change.beforeMode.toString(8)} → ${change.afterMode.toString(8)}`;
324
+ }
325
+ return "";
137
326
  }
138
327
 
139
328
  /** "+6 −2", in the diff colours. */
package/history.ts ADDED
@@ -0,0 +1,252 @@
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
+ }