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 +26 -8
- package/display.ts +198 -9
- package/history.ts +252 -0
- package/index.ts +46 -28
- package/macos-process-cleanup.c +55 -0
- package/overlay-linux.ts +180 -26
- package/overlay-macos.ts +354 -183
- package/package.json +5 -3
- package/placement.ts +306 -0
- package/prelude.ts +131 -41
- package/runner.ts +607 -80
- package/skills/shorthand/SKILL.md +1 -1
- package/skills/shorthand/ast-grep.md +30 -1
- package/skills/shorthand/writing.md +7 -3
package/README.md
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
# pi-shorthand
|
|
2
2
|
|
|
3
|
+

|
|
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.
|
|
8
|
-
|
|
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
|
-
- `
|
|
39
|
-
|
|
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
|
-
-
|
|
46
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
-
|
|
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), ...
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|