pi-shorthand 0.4.0 → 0.6.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
@@ -20,22 +20,17 @@ 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. For Debian and Ubuntu:
23
+ On Linux, install bubblewrap 0.11 or later, a C compiler, and `lsof`. For Debian and Ubuntu, check the available bubblewrap version before installing:
24
24
 
25
25
  ```sh
26
- sudo apt install bubblewrap
26
+ apt-cache policy bubblewrap
27
+ sudo apt install bubblewrap build-essential lsof
27
28
  ```
28
29
 
29
30
  ## Technical choices
30
31
 
31
32
  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
33
 
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.
34
+ 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.
34
35
 
35
- ## Diagnosing slow calls
36
-
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.
40
-
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.
36
+ The `code` tool uses Pi's working directory by default. Pass `cwd` to target another checkout; relative paths are resolved from Pi's working directory. For example, when Pi starts in a bare worktree container, `cwd: "child"` targets its `child` worktree. The chosen directory must be inside a Git worktree.
@@ -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/display.ts CHANGED
@@ -21,8 +21,15 @@ const INLINE_DIFF_LINES = 40; // a longer diff collapses to a list of its files
21
21
  const LISTED_FILES = 8; // …showing this many, then "and N more files"
22
22
  const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
23
23
 
24
- export function callLine(args: { title?: string; timeout?: number; rollback?: string }, theme: Theme): string {
25
- const settings = [args.rollback === "all" && "rollback all", args.timeout && `program timeout ${args.timeout}s`];
24
+ export function callLine(
25
+ args: { title?: string; cwd?: string; timeout?: number; rollback?: string },
26
+ theme: Theme,
27
+ ): string {
28
+ const settings = [
29
+ args.cwd && `cwd ${args.cwd}`,
30
+ args.rollback === "all" && "rollback all",
31
+ args.timeout && `program timeout ${args.timeout}s`,
32
+ ];
26
33
  const suffix = settings.filter(Boolean).join(", ");
27
34
  return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
28
35
  }
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/index.ts CHANGED
@@ -35,7 +35,7 @@ class RunnerError extends Error {
35
35
  // Runs typically take well under a second. Longer transformations can request more time.
36
36
  const DEFAULT_TIMEOUT_SECONDS = 2;
37
37
 
38
- const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
38
+ const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Top-level await and ordinary Bun/Node APIs work. Use repository-relative paths. Set cwd to a checkout path when Pi's working directory is outside the repository, such as a child worktree in a bare worktree container. The program runs in an isolated workspace; changes apply on successful exit by default and the tool reports the diff. Run tests, type-checks and builds separately afterward with the shell tool.
39
39
 
40
40
  Common operations:
41
41
  - edit({ path, oldText, newText }) replaces exactly one literal occurrence; missing or ambiguous text is an error. Use text edits for known source, structural matching when it saves enumerating occurrences or preserves varying syntax.
@@ -70,6 +70,12 @@ export default function (pi: ExtensionAPI) {
70
70
  parameters: Type.Object({
71
71
  title: Type.String({ description: "A few words describing the change, shown to the user" }),
72
72
  program: Type.String({ description: "TypeScript program run with Bun (top-level await allowed)" }),
73
+ cwd: Type.Optional(
74
+ Type.String({
75
+ description:
76
+ "Working directory for the program; relative to Pi's working directory, or an absolute path. Defaults to Pi's working directory. Must be inside a git worktree.",
77
+ }),
78
+ ),
73
79
  rollback: Type.Optional(
74
80
  StringEnum(["all", "file"] as const, {
75
81
  description:
@@ -95,7 +101,7 @@ export default function (pi: ExtensionAPI) {
95
101
  try {
96
102
  result = await runWithBun(
97
103
  {
98
- cwd: ctx.cwd,
104
+ cwd: path.resolve(ctx.cwd, params.cwd ?? "."),
99
105
  program: params.program,
100
106
  timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
101
107
  rollback: params.rollback ?? "file",
@@ -0,0 +1,162 @@
1
+ /** Host-side journal broker for the native Linux observer. */
2
+ import { createHash, randomBytes } from "node:crypto";
3
+ import * as fs from "node:fs/promises";
4
+ import { createServer, type Socket } from "node:net";
5
+ import { once } from "node:events";
6
+ import { homedir } from "node:os";
7
+ import * as path from "node:path";
8
+ import { $ } from "bun";
9
+ import { TransactionJournal } from "./transaction-journal.ts";
10
+ import { diagnosticCounter, measure } from "./diagnostics.ts";
11
+
12
+ type InvocationState = "expected" | "active" | "finished";
13
+
14
+ const FRAME_HEADER_SIZE = 9;
15
+ const MAX_FIELD_SIZE = 4096;
16
+ const MAX_FRAME_SIZE = FRAME_HEADER_SIZE + 2 * MAX_FIELD_SIZE;
17
+
18
+ function decodeFrame(buffer: Buffer): { kind: string; first: string; second: string } | undefined {
19
+ if (buffer.length > MAX_FRAME_SIZE) throw new Error("Oversized observation request");
20
+ if (buffer.length < FRAME_HEADER_SIZE) return undefined;
21
+ const firstSize = buffer.readUInt32BE(1);
22
+ const secondSize = buffer.readUInt32BE(5);
23
+ if (firstSize > MAX_FIELD_SIZE || secondSize > MAX_FIELD_SIZE) throw new Error("Oversized observation path");
24
+ const frameSize = FRAME_HEADER_SIZE + firstSize + secondSize;
25
+ if (buffer.length < frameSize) return undefined;
26
+ if (buffer.length !== frameSize) throw new Error("Unexpected observation framing");
27
+
28
+ const firstBytes = buffer.subarray(FRAME_HEADER_SIZE, FRAME_HEADER_SIZE + firstSize);
29
+ const secondBytes = buffer.subarray(FRAME_HEADER_SIZE + firstSize);
30
+ const first = firstBytes.toString();
31
+ const second = secondBytes.toString();
32
+ if (!Buffer.from(first).equals(firstBytes) || !Buffer.from(second).equals(secondBytes))
33
+ throw new Error("Non-UTF8 observation path");
34
+ return { kind: String.fromCharCode(buffer[0]), first, second };
35
+ }
36
+
37
+ export async function openLinuxObservation(repo: string, temporary: string) {
38
+ const helper = await measure("preparing native observer", observerHelper);
39
+ const socketPath = path.join(temporary, "observer.sock");
40
+ const invocations = new Map<string, InvocationState>();
41
+ const journal = new TransactionJournal(repo);
42
+ const connections = new Set<Socket>();
43
+ const completedConnections = new Set<Socket>();
44
+ const pending = new Set<Promise<void>>();
45
+ let closing = false;
46
+ const server = createServer((socket) => {
47
+ connections.add(socket);
48
+ const operation = (async () => {
49
+ let authenticated = false;
50
+ let token: string | undefined;
51
+ let finished = false;
52
+ let buffer: Buffer = Buffer.alloc(0);
53
+ try {
54
+ for await (const data of socket) {
55
+ buffer = Buffer.concat([buffer, Buffer.from(data)]);
56
+ const frame = decodeFrame(buffer);
57
+ if (!frame) continue;
58
+ if (finished) throw new Error("Unexpected observation framing");
59
+ const { kind, first, second } = frame;
60
+ buffer = Buffer.alloc(0);
61
+ if (!authenticated) {
62
+ if (closing || kind !== "H" || invocations.get(first) !== "expected" || second)
63
+ throw new Error("Unauthenticated observer");
64
+ authenticated = true;
65
+ token = first;
66
+ invocations.set(token, "active");
67
+ } else if (kind === "F" && !first && !second) {
68
+ finished = true;
69
+ invocations.set(token!, "finished");
70
+ completedConnections.add(socket);
71
+ } else if (kind === "X") throw new Error(first);
72
+ else if (kind === "R") await journal.observeRename(first, second);
73
+ else if (second) throw new Error("Unexpected second observation path");
74
+ else if (kind === "T") await journal.observeTree(first);
75
+ else if (kind === "D") await journal.observeDirectory(first);
76
+ else if (kind === "M") await journal.observe(first, "metadata");
77
+ else if (kind === "C") await journal.observe(first, "contents");
78
+ else throw new Error(`Unknown observation kind ${kind}`);
79
+ socket.write(Buffer.from([1]));
80
+ }
81
+ if (!authenticated || !finished || buffer.length) throw new Error("Observer disconnected before completion");
82
+ } catch (error) {
83
+ journal.invalidate(error instanceof Error ? error.message : String(error));
84
+ socket.destroy();
85
+ } finally {
86
+ connections.delete(socket);
87
+ completedConnections.delete(socket);
88
+ }
89
+ })();
90
+ pending.add(operation);
91
+ void operation.finally(() => pending.delete(operation));
92
+ });
93
+ server.listen(socketPath);
94
+ try {
95
+ await once(server, "listening");
96
+ await fs.chmod(socketPath, 0o600);
97
+ } catch (error) {
98
+ for (const socket of connections) socket.destroy();
99
+ if (server.listening) await new Promise<void>((resolve) => server.close(() => resolve()));
100
+ throw error;
101
+ }
102
+ return {
103
+ journal,
104
+ wrap: (command: string[]) => {
105
+ const secret = randomBytes(32).toString("hex");
106
+ invocations.set(secret, "expected");
107
+ return [helper, socketPath, secret, repo, ...command];
108
+ },
109
+ async finish() {
110
+ closing = true;
111
+ const stopped = new Promise<void>((resolve) => server.close(() => resolve()));
112
+ if ([...connections].some((socket) => !completedConnections.has(socket))) {
113
+ journal.invalidate("Observer remained active after execution stopped");
114
+ }
115
+ for (const socket of connections) {
116
+ if (completedConnections.has(socket)) socket.end();
117
+ else socket.destroy();
118
+ }
119
+ await Promise.all([stopped, ...pending]);
120
+ if (!invocations.size || [...invocations.values()].some((state) => state !== "finished"))
121
+ journal.invalidate("Not every expected observer completed");
122
+ diagnosticCounter("observed entries", journal.entryCount);
123
+ diagnosticCounter("content captures", journal.contentCaptureCount);
124
+ await journal.seal();
125
+ return await measure("validating observed dependencies", () => journal.conflicts());
126
+ },
127
+ };
128
+ }
129
+
130
+ async function observerHelper(): Promise<string> {
131
+ const source = path.join(import.meta.dir, "linux-observer.c");
132
+ const digest = createHash("sha256")
133
+ .update(await fs.readFile(source))
134
+ .digest("hex")
135
+ .slice(0, 16);
136
+ const directory = path.join(homedir(), ".cache", "pi-shorthand", "native");
137
+ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
138
+ const info = await fs.lstat(directory);
139
+ if (!info.isDirectory() || info.isSymbolicLink() || info.uid !== process.getuid?.())
140
+ throw new Error("Unsafe native helper directory");
141
+ await fs.chmod(directory, 0o700);
142
+ const helper = path.join(directory, `linux-observer-${digest}`);
143
+ try {
144
+ const existing = await fs.lstat(helper);
145
+ if (!existing.isFile() || existing.isSymbolicLink() || existing.uid !== info.uid || existing.mode & 0o022)
146
+ throw new Error("Unsafe native observer helper");
147
+ return helper;
148
+ } catch (error) {
149
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
150
+ }
151
+ const compiler = Bun.which("cc") ?? Bun.which("gcc");
152
+ if (!compiler) throw new Error("The Linux code tool needs a C compiler to build its cached observation helper.");
153
+ const temporary = `${helper}.${randomBytes(8).toString("hex")}.tmp`;
154
+ try {
155
+ await $`${compiler} -O2 -Wall -Wextra ${source} -o ${temporary}`.quiet();
156
+ await fs.chmod(temporary, 0o700);
157
+ await fs.rename(temporary, helper);
158
+ } finally {
159
+ await fs.rm(temporary, { force: true });
160
+ }
161
+ return helper;
162
+ }