pi-shorthand 0.3.2 → 0.5.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 +3 -3
- package/agentfs-database.ts +141 -0
- package/api.d.ts +1 -0
- package/diagnostics.ts +142 -0
- package/display.ts +41 -3
- package/file-outcomes.ts +1 -0
- package/format.ts +8 -0
- package/index.ts +92 -15
- package/linux-observation.ts +162 -0
- package/linux-observer.c +612 -0
- package/lsp-client.ts +183 -0
- package/nfs-observer.ts +212 -0
- package/nfs-proxy.ts +226 -0
- package/nfs-worker.ts +173 -0
- package/overlay-linux.ts +126 -125
- package/overlay-macos.ts +80 -69
- package/package.json +5 -4
- package/prelude.ts +47 -10
- package/rpc-records.ts +88 -0
- package/runner.ts +180 -27
- package/skills/shorthand/SKILL.md +15 -1
- package/skills/shorthand/advanced-refactors.md +14 -17
- package/transaction-journal.ts +317 -0
- package/typescript-refactors.ts +138 -0
- package/workspace-edit.ts +111 -0
package/README.md
CHANGED
|
@@ -20,14 +20,14 @@ xcode-select --install
|
|
|
20
20
|
curl -fsSL https://agentfs.ai/install | bash
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
On Linux, install bubblewrap 0.9 or later
|
|
23
|
+
On Linux, install bubblewrap 0.9 or later, a C compiler, and `lsof`. For Debian and Ubuntu:
|
|
24
24
|
|
|
25
25
|
```sh
|
|
26
|
-
sudo apt install bubblewrap
|
|
26
|
+
sudo apt install bubblewrap build-essential lsof
|
|
27
27
|
```
|
|
28
28
|
|
|
29
29
|
## Technical choices
|
|
30
30
|
|
|
31
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.
|
|
32
32
|
|
|
33
|
-
Programs edit
|
|
33
|
+
Programs edit a private workspace, with host files outside the repository kept read-only. By default, a failure keeps completed files and rolls back failed or interrupted edits. Concurrent edits can cause a run to be rejected; conflict detection is best-effort, not an atomic commit.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/** Reuse only pristine, never-mounted AgentFS databases; each run gets independent copies. */
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import { constants } from "node:fs";
|
|
5
|
+
import { homedir } from "node:os";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { $ } from "bun";
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
|
|
10
|
+
async function ownedDirectory(directory: string): Promise<void> {
|
|
11
|
+
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
|
|
12
|
+
const stat = await fs.lstat(directory);
|
|
13
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 0o077)
|
|
14
|
+
throw new Error(`Unsafe AgentFS template directory: ${directory}`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function templateKey(agentfs: string, repo: string): Promise<string> {
|
|
18
|
+
const executable = await fs.stat(agentfs, { bigint: true });
|
|
19
|
+
const repository = await fs.stat(repo, { bigint: true });
|
|
20
|
+
// Do not tie the template to checkout contents: it contains schema and the base
|
|
21
|
+
// path, not source files. Replacing either the executable or root invalidates it.
|
|
22
|
+
return createHash("sha256")
|
|
23
|
+
.update(
|
|
24
|
+
JSON.stringify(
|
|
25
|
+
[
|
|
26
|
+
1,
|
|
27
|
+
process.getuid?.(),
|
|
28
|
+
process.getgid?.(),
|
|
29
|
+
agentfs,
|
|
30
|
+
executable.dev,
|
|
31
|
+
executable.ino,
|
|
32
|
+
executable.size,
|
|
33
|
+
executable.mtimeNs,
|
|
34
|
+
executable.ctimeNs,
|
|
35
|
+
repo,
|
|
36
|
+
repository.dev,
|
|
37
|
+
repository.ino,
|
|
38
|
+
],
|
|
39
|
+
(_, value) => (typeof value === "bigint" ? String(value) : value),
|
|
40
|
+
),
|
|
41
|
+
)
|
|
42
|
+
.digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function copyTemplate(template: string, destination: string): Promise<void> {
|
|
46
|
+
const directory = await fs.lstat(template);
|
|
47
|
+
if (
|
|
48
|
+
!directory.isDirectory() ||
|
|
49
|
+
directory.isSymbolicLink() ||
|
|
50
|
+
directory.uid !== process.getuid?.() ||
|
|
51
|
+
directory.mode & 0o077
|
|
52
|
+
)
|
|
53
|
+
throw new Error("Unsafe AgentFS database template");
|
|
54
|
+
const names = await fs.readdir(template);
|
|
55
|
+
if (!names.includes("run.db")) throw new Error("Incomplete AgentFS database template");
|
|
56
|
+
await fs.mkdir(destination, { recursive: true, mode: 0o700 });
|
|
57
|
+
for (const name of names) {
|
|
58
|
+
const source = path.join(template, name);
|
|
59
|
+
const stat = await fs.lstat(source);
|
|
60
|
+
if (
|
|
61
|
+
!(name === "run.db" || name === "run.db-wal") ||
|
|
62
|
+
!stat.isFile() ||
|
|
63
|
+
stat.uid !== process.getuid?.() ||
|
|
64
|
+
stat.mode & 0o077 ||
|
|
65
|
+
stat.nlink !== 1
|
|
66
|
+
)
|
|
67
|
+
throw new Error("Unsafe AgentFS database template entry");
|
|
68
|
+
// No hardlinks: serving a transaction must never mutate the template.
|
|
69
|
+
await fs.copyFile(source, path.join(destination, name), constants.COPYFILE_EXCL);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Inspect the private copy, so SQLite never opens or modifies the shared template. */
|
|
74
|
+
function validateEmptyDatabase(file: string, repo: string): void {
|
|
75
|
+
const db = new Database(file, { readonly: true, strict: true });
|
|
76
|
+
try {
|
|
77
|
+
const base = db.query("SELECT value FROM fs_overlay_config WHERE key = 'base_path'").get() as
|
|
78
|
+
| { value: string }
|
|
79
|
+
| undefined;
|
|
80
|
+
const root = db.query("SELECT mode, uid, gid FROM fs_inode WHERE ino = 1").get() as
|
|
81
|
+
| { mode: number; uid: number; gid: number }
|
|
82
|
+
| undefined;
|
|
83
|
+
if (
|
|
84
|
+
base?.value !== repo ||
|
|
85
|
+
!root ||
|
|
86
|
+
(root.mode & 0o170000) !== 0o040000 ||
|
|
87
|
+
root.uid !== process.getuid?.() ||
|
|
88
|
+
root.gid !== process.getgid?.()
|
|
89
|
+
)
|
|
90
|
+
throw new Error("AgentFS template has the wrong base or root identity");
|
|
91
|
+
for (const table of ["fs_dentry", "fs_whiteout", "fs_origin", "fs_data", "fs_symlink", "kv_store", "tool_calls"]) {
|
|
92
|
+
const count = db.query(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number };
|
|
93
|
+
if (count.count !== 0) throw new Error(`AgentFS template contains private ${table} state`);
|
|
94
|
+
}
|
|
95
|
+
const inodes = db.query("SELECT COUNT(*) AS count FROM fs_inode").get() as { count: number };
|
|
96
|
+
if (inodes.count !== 1) throw new Error("AgentFS template contains private inode state");
|
|
97
|
+
} finally {
|
|
98
|
+
db.close();
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function createAgentFsDatabase(
|
|
103
|
+
agentfs: string,
|
|
104
|
+
base: string,
|
|
105
|
+
tempDir: string,
|
|
106
|
+
cache = path.join(homedir(), ".cache", "pi-shorthand", "agentfs-templates"),
|
|
107
|
+
): Promise<string> {
|
|
108
|
+
const executable = await fs.realpath(Bun.which(agentfs) ?? agentfs);
|
|
109
|
+
const repo = await fs.realpath(base);
|
|
110
|
+
await ownedDirectory(cache);
|
|
111
|
+
const key = await templateKey(executable, repo);
|
|
112
|
+
const template = path.join(cache, key);
|
|
113
|
+
const existing = await fs.lstat(template).catch((error: NodeJS.ErrnoException) => {
|
|
114
|
+
if (error.code !== "ENOENT") throw error;
|
|
115
|
+
return null;
|
|
116
|
+
});
|
|
117
|
+
if (!existing) {
|
|
118
|
+
const staging = await fs.mkdtemp(path.join(cache, ".initializing-"));
|
|
119
|
+
try {
|
|
120
|
+
await $`${executable} init run --base ${repo}`.cwd(staging).quiet();
|
|
121
|
+
if (key !== (await templateKey(executable, repo)))
|
|
122
|
+
throw new Error("AgentFS or repository changed during initialization");
|
|
123
|
+
const database = path.join(staging, ".agentfs");
|
|
124
|
+
await fs.chmod(database, 0o700);
|
|
125
|
+
for (const name of await fs.readdir(database)) await fs.chmod(path.join(database, name), 0o600);
|
|
126
|
+
// Publish only after the initializing process has exited, including its
|
|
127
|
+
// database sidecars. Concurrent creators can safely use the winner.
|
|
128
|
+
await fs.rename(database, template).catch((error: NodeJS.ErrnoException) => {
|
|
129
|
+
if (error.code !== "EEXIST" && error.code !== "ENOTEMPTY") throw error;
|
|
130
|
+
});
|
|
131
|
+
} finally {
|
|
132
|
+
await fs.rm(staging, { recursive: true, force: true });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const destination = path.join(tempDir, ".agentfs");
|
|
136
|
+
await copyTemplate(template, destination);
|
|
137
|
+
validateEmptyDatabase(path.join(destination, "run.db"), repo);
|
|
138
|
+
if (key !== (await templateKey(executable, repo)))
|
|
139
|
+
throw new Error("AgentFS or repository changed while preparing the transaction database");
|
|
140
|
+
return path.join(destination, "run.db");
|
|
141
|
+
}
|
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 === "all" && "rollback all", 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
|
}
|
|
@@ -83,14 +84,51 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
|
|
|
83
84
|
if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
|
|
84
85
|
if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
|
|
85
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
|
+
}
|
|
86
92
|
return lines;
|
|
87
93
|
}
|
|
88
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
|
+
|
|
89
127
|
/** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
|
|
90
128
|
function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
|
|
91
129
|
const muted = (text: string) => theme.fg("muted", text);
|
|
92
130
|
const took = muted(` · ${(run.durationMs / 1000).toFixed(1)}s`);
|
|
93
|
-
const failure = run.timedOut ? `
|
|
131
|
+
const failure = run.timedOut ? `Program timed out after ${run.timeoutMs / 1000}s` : "Failed";
|
|
94
132
|
const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
|
|
95
133
|
|
|
96
134
|
if (run.conflicts.length > 0) {
|
package/file-outcomes.ts
CHANGED
|
@@ -11,6 +11,7 @@ export type FileOutcomeEvent =
|
|
|
11
11
|
|
|
12
12
|
const descriptor = process.env.PI_SHORTHAND_OUTCOMES_FD;
|
|
13
13
|
const root = process.env.PI_SHORTHAND_EXECUTION_ROOT;
|
|
14
|
+
export { root as executionRoot };
|
|
14
15
|
const forceInspectionFailure = process.env.PI_SHORTHAND_INSPECTION_FAILURE === "1";
|
|
15
16
|
// Subprocesses do not inherit descriptor 3 by default, so do not advertise it to them.
|
|
16
17
|
delete process.env.PI_SHORTHAND_OUTCOMES_FD;
|
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");
|
package/index.ts
CHANGED
|
@@ -12,8 +12,25 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
12
12
|
import { type ExtensionAPI, type Theme, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { Text } from "@earendil-works/pi-tui";
|
|
14
14
|
import { Type } from "typebox";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
callLine,
|
|
17
|
+
countLines,
|
|
18
|
+
fileMetadataSummary,
|
|
19
|
+
resultLines,
|
|
20
|
+
timingBreakdown,
|
|
21
|
+
unstructuredResultText,
|
|
22
|
+
} from "./display.ts";
|
|
16
23
|
import type { FileChange, RunOptions, RunResult } from "./runner.ts";
|
|
24
|
+
import { type Diagnostics, completeDiagnostics, diagnosticLines } from "./diagnostics.ts";
|
|
25
|
+
|
|
26
|
+
class RunnerError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
message: string,
|
|
29
|
+
readonly diagnostics: Diagnostics,
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
// Runs typically take well under a second. Longer transformations can request more time.
|
|
19
36
|
const DEFAULT_TIMEOUT_SECONDS = 2;
|
|
@@ -26,6 +43,8 @@ Common operations:
|
|
|
26
43
|
- sg.rewrite(pattern, replacement, files?) discovers and rewrites matching code; omit files for the working directory. $X captures one node; $$$X captures a sequence.
|
|
27
44
|
- sg.one(pattern, files?) selects exactly one match; sg.find returns an array. sg.rewrite also accepts a selected match or array without a file scope.
|
|
28
45
|
- A rewrite callback receives a match and returns text, a native node.replace(text) edit, or null to skip. Return native edits to apply them. Pass selected arrays together for independent edits; select again after changing their file.
|
|
46
|
+
- await ts.rename({ file, symbol, to }) renames one resolved TypeScript symbol across the project without changing unrelated names.
|
|
47
|
+
- await ts.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
|
|
29
48
|
|
|
30
49
|
See the shorthand skill for common writes. For extraction, complex rewrites or other languages, read its advanced-refactors.md guide. The default timeout is two seconds; request more for longer programs.`;
|
|
31
50
|
|
|
@@ -72,16 +91,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
72
91
|
});
|
|
73
92
|
}, 500);
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
let result: RunResult;
|
|
95
|
+
try {
|
|
96
|
+
result = await runWithBun(
|
|
97
|
+
{
|
|
98
|
+
cwd: ctx.cwd,
|
|
99
|
+
program: params.program,
|
|
100
|
+
timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
101
|
+
rollback: params.rollback ?? "file",
|
|
102
|
+
},
|
|
103
|
+
signal,
|
|
104
|
+
(step) => (latest = step),
|
|
105
|
+
);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!(error instanceof RunnerError)) throw error;
|
|
108
|
+
return {
|
|
109
|
+
isError: true,
|
|
110
|
+
content: [{ type: "text" as const, text: [error.message, ...diagnosticLines(error.diagnostics)].join("\n") }],
|
|
111
|
+
details: { infrastructureError: error.message, diagnostics: error.diagnostics },
|
|
112
|
+
};
|
|
113
|
+
} finally {
|
|
114
|
+
clearInterval(progress);
|
|
115
|
+
}
|
|
85
116
|
return {
|
|
86
117
|
content: [{ type: "text", text: textForModel(result, toolCallId) }],
|
|
87
118
|
details: result,
|
|
@@ -107,6 +138,18 @@ export function renderCodeResult(
|
|
|
107
138
|
const progress = (result.details as { progress?: string } | undefined)?.progress;
|
|
108
139
|
return new Text(theme.fg("muted", progress ? `running… ${progress}` : "running…"), 0, 0);
|
|
109
140
|
}
|
|
141
|
+
if (result.details && typeof result.details === "object" && "infrastructureError" in result.details) {
|
|
142
|
+
const failure = result.details as { infrastructureError: string; diagnostics: Diagnostics };
|
|
143
|
+
return new Text(
|
|
144
|
+
[
|
|
145
|
+
theme.fg("error", failure.infrastructureError),
|
|
146
|
+
"",
|
|
147
|
+
...diagnosticLines(failure.diagnostics).map((line) => theme.fg("muted", line)),
|
|
148
|
+
].join("\n"),
|
|
149
|
+
0,
|
|
150
|
+
0,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
110
153
|
const run = result.details as RunResult | undefined;
|
|
111
154
|
if (!run) return new Text(theme.fg("error", unstructuredResultText(result.content)), 0, 0);
|
|
112
155
|
return new Text(resultLines(run, expanded, theme).join("\n"), 0, 0);
|
|
@@ -122,6 +165,13 @@ export function runWithBun(
|
|
|
122
165
|
signal?: AbortSignal,
|
|
123
166
|
onProgress?: (step: string) => void,
|
|
124
167
|
): Promise<RunResult> {
|
|
168
|
+
const startedAt = performance.now();
|
|
169
|
+
let firstEventAt: number | undefined;
|
|
170
|
+
let diagnostics: Diagnostics = { spans: [], counters: {} };
|
|
171
|
+
const finishDiagnostics = () => {
|
|
172
|
+
const now = performance.now();
|
|
173
|
+
return completeDiagnostics(diagnostics, now - startedAt, (firstEventAt ?? now) - startedAt);
|
|
174
|
+
};
|
|
125
175
|
return new Promise((resolve, reject) => {
|
|
126
176
|
if (signal?.aborted) return reject(new Error("Aborted"));
|
|
127
177
|
const runner = spawn("bun", [path.join(import.meta.dirname, "runner.ts")], {
|
|
@@ -146,14 +196,23 @@ export function runWithBun(
|
|
|
146
196
|
progressBuffer = lines.pop() ?? "";
|
|
147
197
|
for (const line of lines) {
|
|
148
198
|
try {
|
|
149
|
-
const event = JSON.parse(line) as { step?: unknown };
|
|
199
|
+
const event = JSON.parse(line) as { step?: unknown; diagnostics?: Diagnostics };
|
|
200
|
+
firstEventAt ??= performance.now();
|
|
201
|
+
if (event.diagnostics) {
|
|
202
|
+
diagnostics = event.diagnostics;
|
|
203
|
+
const active = diagnostics.spans.findLast((span) => span.durationMs === undefined);
|
|
204
|
+
if (active) onProgress?.(active.name);
|
|
205
|
+
}
|
|
150
206
|
if (typeof event.step === "string") onProgress?.(event.step);
|
|
151
207
|
} catch {
|
|
152
208
|
// Progress is advisory; malformed events do not affect the run.
|
|
153
209
|
}
|
|
154
210
|
}
|
|
155
211
|
});
|
|
156
|
-
runner.on("error",
|
|
212
|
+
runner.on("error", (error) => {
|
|
213
|
+
signal?.removeEventListener("abort", stop);
|
|
214
|
+
reject(new RunnerError(error.message, finishDiagnostics()));
|
|
215
|
+
});
|
|
157
216
|
runner.on("close", (code) => {
|
|
158
217
|
signal?.removeEventListener("abort", stop);
|
|
159
218
|
try {
|
|
@@ -162,10 +221,24 @@ export function runWithBun(
|
|
|
162
221
|
// nothing left
|
|
163
222
|
}
|
|
164
223
|
if (code === 0) {
|
|
165
|
-
|
|
224
|
+
let result: RunResult;
|
|
225
|
+
try {
|
|
226
|
+
result = JSON.parse(stdout);
|
|
227
|
+
} catch {
|
|
228
|
+
reject(new RunnerError("Runner returned invalid JSON", finishDiagnostics()));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
diagnostics = result.diagnostics ?? diagnostics;
|
|
232
|
+
result.diagnostics = finishDiagnostics();
|
|
166
233
|
if (!signal?.aborted || result.applied.length > 0 || result.cleanupWarnings.length > 0) resolve(result);
|
|
167
234
|
else reject(new Error("Aborted"));
|
|
168
|
-
} else
|
|
235
|
+
} else
|
|
236
|
+
reject(
|
|
237
|
+
new RunnerError(
|
|
238
|
+
stderr.trim() || (signal?.aborted ? "Aborted" : `runner exited with ${code}`),
|
|
239
|
+
finishDiagnostics(),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
169
242
|
});
|
|
170
243
|
|
|
171
244
|
runner.stdin.end(JSON.stringify(options));
|
|
@@ -205,6 +278,8 @@ function fileLine(change: FileChange): string {
|
|
|
205
278
|
|
|
206
279
|
function textForModel(run: RunResult, toolCallId: string): string {
|
|
207
280
|
const lines = [summaryLine(run)];
|
|
281
|
+
const timing = timingBreakdown(run);
|
|
282
|
+
if (timing) lines.push(`Timing: ${timing}`);
|
|
208
283
|
|
|
209
284
|
if (run.applied.length === 0 && run.changes.length > 0) {
|
|
210
285
|
lines.push("The real workspace is unchanged. Below is the candidate diff.");
|
|
@@ -232,6 +307,8 @@ function textForModel(run: RunResult, toolCallId: string): string {
|
|
|
232
307
|
// On failure the error goes last, where it's easiest to find; on success, the diff does.
|
|
233
308
|
if (run.exitCode === 0 && run.conflicts.length === 0) lines.push(...output, ...diff);
|
|
234
309
|
else lines.push(...diff, ...output);
|
|
310
|
+
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run.timeoutMs || run.exitCode !== 0))
|
|
311
|
+
lines.push("", ...diagnosticLines(run.diagnostics));
|
|
235
312
|
return lines.join("\n");
|
|
236
313
|
}
|
|
237
314
|
|