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 +23 -82
- package/api.d.ts +1 -0
- package/diagnostics.ts +142 -0
- package/display.ts +46 -9
- package/file-outcomes.ts +100 -0
- package/format.ts +8 -0
- package/index.ts +128 -51
- package/lsp-client.ts +183 -0
- package/overlay-linux.ts +155 -52
- package/overlay-macos.ts +4 -9
- package/package.json +5 -4
- package/placement.ts +21 -0
- package/prelude.ts +91 -28
- package/runner.ts +230 -91
- package/skills/shorthand/SKILL.md +15 -1
- package/skills/shorthand/advanced-refactors.md +23 -21
- package/typescript-refactors.ts +138 -0
- package/workspace-edit.ts +111 -0
- package/history.ts +0 -252
package/README.md
CHANGED
|
@@ -1,100 +1,41 @@
|
|
|
1
1
|
# pi-shorthand
|
|
2
2
|
|
|
3
|
-

|
|
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
|
-
|
|
23
|
+
On Linux, install bubblewrap 0.9 or later. For Debian and Ubuntu:
|
|
19
24
|
|
|
20
|
-
|
|
25
|
+
```sh
|
|
26
|
+
sudo apt install bubblewrap
|
|
27
|
+
```
|
|
21
28
|
|
|
22
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
+
## Diagnosing slow calls
|
|
33
36
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
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 === "
|
|
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
|
|
73
|
-
:
|
|
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 =
|
|
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 ? `
|
|
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) {
|
package/file-outcomes.ts
ADDED
|
@@ -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");
|