pi-shorthand 0.8.0 → 0.10.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 +21 -4
- package/package.json +12 -38
- package/{display.ts → src/display.ts} +10 -69
- package/src/index.ts +174 -0
- package/agentfs-database.ts +0 -141
- package/api.d.ts +0 -15
- package/diagnostics.ts +0 -142
- package/file-outcomes.ts +0 -101
- package/format.ts +0 -168
- package/index.ts +0 -361
- package/linux-observation.ts +0 -162
- package/linux-observer.c +0 -708
- package/lsp-client.ts +0 -186
- package/macos-process-cleanup.c +0 -55
- package/model-diff.ts +0 -60
- package/move-analysis.ts +0 -191
- package/move-imports.ts +0 -667
- package/nfs-observer.ts +0 -265
- package/nfs-proxy.ts +0 -226
- package/nfs-worker.ts +0 -173
- package/overlay-linux.ts +0 -228
- package/overlay-macos.ts +0 -469
- package/placement.ts +0 -444
- package/prelude.ts +0 -797
- package/program-clock.ts +0 -45
- package/program-lint.ts +0 -125
- package/rpc-records.ts +0 -88
- package/runner.ts +0 -1321
- package/skills/shorthand/SKILL.md +0 -118
- package/skills/shorthand/advanced-refactors.md +0 -173
- package/text-format.ts +0 -22
- package/transaction-journal.ts +0 -317
- package/typescript-refactors.ts +0 -181
- package/workspace-edit.ts +0 -114
package/README.md
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
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, with first-class support for TypeScript and JavaScript.
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
-
Install
|
|
9
|
+
Install [Bun](https://bun.sh) 1.4 or later, which runs the programs:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
curl -fsSL https://bun.sh/install | bash
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Then install pi-shorthand:
|
|
10
16
|
|
|
11
17
|
```sh
|
|
12
18
|
pi install npm:pi-shorthand
|
|
13
|
-
npx @getgrit/cli init --global
|
|
14
19
|
```
|
|
15
20
|
|
|
16
21
|
On macOS, install clang and AgentFS:
|
|
@@ -31,6 +36,18 @@ sudo apt install bubblewrap build-essential lsof
|
|
|
31
36
|
|
|
32
37
|
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.
|
|
33
38
|
|
|
34
|
-
Programs edit a private workspace, with host files outside the repository kept read-only.
|
|
39
|
+
Programs edit a private workspace, with host files outside the repository kept read-only. Agents are good at improving things a step at a time, so changes are applied a whole file at a time and the agent is told which worked and which didn't. Concurrent edits can cause a run to be rejected; conflict detection is best-effort, not an atomic commit.
|
|
35
40
|
|
|
36
41
|
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.
|
|
42
|
+
|
|
43
|
+
## Without Pi
|
|
44
|
+
|
|
45
|
+
The engine is published separately as [shorthand-code](https://github.com/sebinsua/shortsight/tree/main/packages/shorthand-code), with a `shorthand` command that runs a program from a file or stdin and prints the same result. See `shorthand --help`.
|
|
46
|
+
|
|
47
|
+
## Code graph (optional)
|
|
48
|
+
|
|
49
|
+
Install [`sightread`](https://github.com/sebinsua/shortsight/tree/main/packages/sightread) and programs can also ask the TypeScript compiler what calls what, with exact line ranges, before they edit:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
npm i -g sightread
|
|
53
|
+
```
|
package/package.json
CHANGED
|
@@ -1,58 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-shorthand",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"description": "Pi tool for editing repositories with Bun programs, with first-class support for TypeScript and JavaScript.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ast-grep",
|
|
7
7
|
"bun",
|
|
8
8
|
"code-mode",
|
|
9
|
-
"gritql",
|
|
10
9
|
"overlay",
|
|
11
10
|
"pi",
|
|
12
11
|
"pi-extension",
|
|
13
12
|
"pi-package"
|
|
14
13
|
],
|
|
15
|
-
"homepage": "https://github.com/sebinsua/pi-shorthand#readme",
|
|
16
|
-
"bugs": "https://github.com/sebinsua/
|
|
14
|
+
"homepage": "https://github.com/sebinsua/shortsight/tree/main/packages/pi-shorthand#readme",
|
|
15
|
+
"bugs": "https://github.com/sebinsua/shortsight/issues",
|
|
17
16
|
"license": "MIT",
|
|
18
17
|
"author": "Seb Insua",
|
|
19
18
|
"repository": {
|
|
20
19
|
"type": "git",
|
|
21
|
-
"url": "git+https://github.com/sebinsua/
|
|
20
|
+
"url": "git+https://github.com/sebinsua/shortsight.git",
|
|
21
|
+
"directory": "packages/pi-shorthand"
|
|
22
22
|
},
|
|
23
23
|
"files": [
|
|
24
|
-
"
|
|
25
|
-
"*.ts",
|
|
26
|
-
"skills",
|
|
24
|
+
"src",
|
|
27
25
|
"README.md"
|
|
28
26
|
],
|
|
29
27
|
"type": "module",
|
|
30
|
-
"scripts": {
|
|
31
|
-
"test": "bun test",
|
|
32
|
-
"typecheck": "tsc -p .",
|
|
33
|
-
"lint": "oxlint --deny-warnings",
|
|
34
|
-
"format": "oxfmt",
|
|
35
|
-
"check": "tsc -p . && oxlint --deny-warnings && oxfmt --check",
|
|
36
|
-
"setup:grit": "grit init --global",
|
|
37
|
-
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true"
|
|
38
|
-
},
|
|
39
28
|
"dependencies": {
|
|
40
|
-
"
|
|
41
|
-
"@ast-grep/napi": "^0.45.3",
|
|
42
|
-
"@getgrit/cli": "0.1.0-alpha.1743007075",
|
|
43
|
-
"diff": "^9.0.0",
|
|
44
|
-
"typescript": "7.0.2",
|
|
45
|
-
"vscode-jsonrpc": "^9.0.2"
|
|
46
|
-
},
|
|
47
|
-
"devDependencies": {
|
|
48
|
-
"@earendil-works/pi-ai": "^0.85.1",
|
|
49
|
-
"@earendil-works/pi-coding-agent": "^0.85.1",
|
|
50
|
-
"@earendil-works/pi-tui": "^0.85.1",
|
|
51
|
-
"@types/bun": "^1.4.2",
|
|
52
|
-
"@types/node": "^26.6.1",
|
|
53
|
-
"oxfmt": "^0.68.0",
|
|
54
|
-
"oxlint": "^1.83.0",
|
|
55
|
-
"typebox": "^1.3.7"
|
|
29
|
+
"shorthand-code": "0.10.0"
|
|
56
30
|
},
|
|
57
31
|
"peerDependencies": {
|
|
58
32
|
"@earendil-works/pi-ai": "*",
|
|
@@ -74,13 +48,13 @@
|
|
|
74
48
|
"optional": true
|
|
75
49
|
}
|
|
76
50
|
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"bun": ">=1.4"
|
|
53
|
+
},
|
|
77
54
|
"pi": {
|
|
78
55
|
"extensions": [
|
|
79
|
-
"./index.ts"
|
|
80
|
-
],
|
|
81
|
-
"skills": [
|
|
82
|
-
"./skills"
|
|
56
|
+
"./src/index.ts"
|
|
83
57
|
],
|
|
84
|
-
"image": "https://raw.githubusercontent.com/sebinsua/
|
|
58
|
+
"image": "https://raw.githubusercontent.com/sebinsua/shortsight/main/docs/shorthand.png"
|
|
85
59
|
}
|
|
86
60
|
}
|
|
@@ -8,8 +8,16 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { getLanguageFromPath, highlightCode, keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
type FileChange,
|
|
13
|
+
type RunResult,
|
|
14
|
+
countLines,
|
|
15
|
+
diagnosticLines,
|
|
16
|
+
fileMetadataSummary,
|
|
17
|
+
timeoutBudgetMs,
|
|
18
|
+
timeoutText,
|
|
19
|
+
timingBreakdown,
|
|
20
|
+
} from "shorthand-code";
|
|
13
21
|
|
|
14
22
|
type ToolBackground = "toolSuccessBg" | "toolErrorBg";
|
|
15
23
|
|
|
@@ -99,49 +107,6 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
|
|
|
99
107
|
return lines;
|
|
100
108
|
}
|
|
101
109
|
|
|
102
|
-
const TIMING_LABELS: Record<keyof RunTimings, string> = {
|
|
103
|
-
resolveRepositoryMs: "repository",
|
|
104
|
-
waitForLockMs: "lock wait",
|
|
105
|
-
workspaceSetupMs: "workspace setup",
|
|
106
|
-
programMs: "program",
|
|
107
|
-
scanChangesMs: "change scan",
|
|
108
|
-
formatMs: "formatter",
|
|
109
|
-
workspaceCloseMs: "workspace cleanup",
|
|
110
|
-
checkConflictsMs: "conflict check",
|
|
111
|
-
applyMs: "apply",
|
|
112
|
-
renderDiffMs: "diff",
|
|
113
|
-
unattributedMs: "other",
|
|
114
|
-
};
|
|
115
|
-
|
|
116
|
-
/** Explain calls exceeding the configured program budget, even when no individual phase does. */
|
|
117
|
-
export function timingBreakdown(run: RunResult): string | undefined {
|
|
118
|
-
if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < timeoutBudgetMs(run)) return undefined;
|
|
119
|
-
const significant = Object.entries(run.timings)
|
|
120
|
-
.map(([phase, milliseconds]) => ({
|
|
121
|
-
label: TIMING_LABELS[phase as keyof RunTimings],
|
|
122
|
-
milliseconds,
|
|
123
|
-
}))
|
|
124
|
-
.filter(({ milliseconds }) => milliseconds > 0)
|
|
125
|
-
.toSorted((a, b) => b.milliseconds - a.milliseconds);
|
|
126
|
-
if (significant.length === 0) return undefined;
|
|
127
|
-
return significant.map(({ label, milliseconds }) => `${label} ${formatDuration(milliseconds)}`).join(" · ");
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function formatDuration(milliseconds: number): string {
|
|
131
|
-
return milliseconds < 1_000 ? `${milliseconds}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** e.g. "2s" or "2s plus 60.0s in helpers": helper time does not count toward the timeout. */
|
|
135
|
-
function timeoutText(run: RunResult): string {
|
|
136
|
-
const helpers = run.helperMs ? ` plus ${formatDuration(run.helperMs)} in helpers` : "";
|
|
137
|
-
return `${run.timeoutMs / 1000}s${helpers}`;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/** The longest a run can take before its timeout, including excluded helper time. */
|
|
141
|
-
export function timeoutBudgetMs(run: RunResult): number {
|
|
142
|
-
return run.timeoutMs + (run.helperMs ?? 0);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
110
|
/** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
|
|
146
111
|
function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
|
|
147
112
|
const muted = (text: string) => theme.fg("muted", text);
|
|
@@ -369,17 +334,6 @@ function fileLine(change: FileChange, theme: Theme): string {
|
|
|
369
334
|
return `${theme.fg("accent", change.path)}${kind}${metadata ? theme.fg("muted", ` (${metadata})`) : ""} ${stats([change], theme)}`;
|
|
370
335
|
}
|
|
371
336
|
|
|
372
|
-
export function fileMetadataSummary(change: FileChange): string {
|
|
373
|
-
if (change.beforeType && change.afterType && change.beforeType !== change.afterType) {
|
|
374
|
-
return `${change.beforeType} → ${change.afterType}`;
|
|
375
|
-
}
|
|
376
|
-
if (change.afterType === "symlink" && change.beforeType !== "symlink") return "symlink";
|
|
377
|
-
if (change.beforeMode !== undefined && change.afterMode !== undefined && change.beforeMode !== change.afterMode) {
|
|
378
|
-
return `${change.beforeMode.toString(8)} → ${change.afterMode.toString(8)}`;
|
|
379
|
-
}
|
|
380
|
-
return "";
|
|
381
|
-
}
|
|
382
|
-
|
|
383
337
|
/** "+6 −2", in the diff colours. */
|
|
384
338
|
function stats(changes: FileChange[], theme: Theme): string {
|
|
385
339
|
const counts = changes.map((change) => countLines(change.patch));
|
|
@@ -434,16 +388,3 @@ function toPiDiff(patch: string): string {
|
|
|
434
388
|
}
|
|
435
389
|
return out.join("\n");
|
|
436
390
|
}
|
|
437
|
-
|
|
438
|
-
/** Added and removed lines in a patch. Only lines after the first "@@" count, not the ---/+++ headers. */
|
|
439
|
-
export function countLines(patch: string) {
|
|
440
|
-
let additions = 0;
|
|
441
|
-
let deletions = 0;
|
|
442
|
-
let inHunks = false;
|
|
443
|
-
for (const line of patch.split("\n")) {
|
|
444
|
-
if (line.startsWith("@@")) inHunks = true;
|
|
445
|
-
else if (inHunks && line.startsWith("+")) additions++;
|
|
446
|
-
else if (inHunks && line.startsWith("-")) deletions++;
|
|
447
|
-
}
|
|
448
|
-
return { additions, deletions };
|
|
449
|
-
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `code` tool: the model writes one Bun program that makes a multi-step change to the repository.
|
|
3
|
+
* Its writes go to a copy-on-write overlay; if it succeeds, they're applied and the diff is returned.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import * as path from "node:path";
|
|
8
|
+
import { StringEnum } from "@earendil-works/pi-ai";
|
|
9
|
+
import { type ExtensionAPI, type Theme, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import {
|
|
13
|
+
DEFAULT_TIMEOUT_SECONDS,
|
|
14
|
+
type Diagnostics,
|
|
15
|
+
type RunResult,
|
|
16
|
+
RunnerError,
|
|
17
|
+
diagnosticLines,
|
|
18
|
+
runFailed,
|
|
19
|
+
runWithBun,
|
|
20
|
+
sightreadAvailable,
|
|
21
|
+
textForModel,
|
|
22
|
+
} from "shorthand-code";
|
|
23
|
+
import { callLine, resultLines, unstructuredResultText } from "./display.ts";
|
|
24
|
+
|
|
25
|
+
const DESCRIPTION = `Edit repository files with a TypeScript program run by Bun. Best for changes across many files, repeated edits and semantic TypeScript renames or moves; a small change to one file is quicker as a direct edit. 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.
|
|
26
|
+
|
|
27
|
+
Common operations:
|
|
28
|
+
- 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.
|
|
29
|
+
- await Bun.file(path).text(); await Bun.write(path, text)
|
|
30
|
+
- sg.rewrite(pattern, replacement, files?) discovers and rewrites matching code; omit files for the working directory. $X captures one node; $$$X captures a sequence.
|
|
31
|
+
- 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.
|
|
32
|
+
- 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.
|
|
33
|
+
- await refactor.rename({ file, symbol, to }) renames one resolved TypeScript symbol across the project without changing unrelated names.
|
|
34
|
+
- await refactor.references({ file, symbol }) returns resolved identifier matches that pass straight to sg.rewrite(matches, callback).
|
|
35
|
+
- await refactor.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
|
|
36
|
+
- await refactor.move({ file, symbol, to }) moves a top-level declaration to another file and updates the imports that follow it.
|
|
37
|
+
|
|
38
|
+
See the shorthand skill for renames, moves and call-site migrations. Read its advanced-refactors.md guide only to extract code, move syntax or use rule objects. The default timeout is two seconds; request more for longer programs. Time spent inside the helpers above does not count toward it, up to 60 extra seconds.`;
|
|
39
|
+
|
|
40
|
+
const GRAPH_DESCRIPTION =
|
|
41
|
+
"- await graph.query(request | request[]) queries symbols and relationships; graph results can be passed to sg as scope. The graph shows the repository before this program's edits.\n";
|
|
42
|
+
|
|
43
|
+
export const SKILLS_DIRECTORY = path.join(
|
|
44
|
+
path.dirname(createRequire(import.meta.url).resolve("shorthand-code/package.json")),
|
|
45
|
+
"skills",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
export function codeDescription(graphAvailable: boolean): string {
|
|
49
|
+
return graphAvailable
|
|
50
|
+
? DESCRIPTION.replace("\n\nSee the shorthand skill", `\n${GRAPH_DESCRIPTION}\nSee the shorthand skill`)
|
|
51
|
+
: DESCRIPTION;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export default async function (pi: ExtensionAPI, findGraph: () => Promise<unknown> = sightreadAvailable) {
|
|
55
|
+
// The shorthand skill ships with shorthand-code, whose `shorthand --skill` prints it for other agents.
|
|
56
|
+
pi.on("resources_discover", () => ({ skillPaths: [SKILLS_DIRECTORY] }));
|
|
57
|
+
const graphAvailable = Boolean(await findGraph());
|
|
58
|
+
// A failed run is an error, both for the model and for how Pi shows it. (execute() returns its details
|
|
59
|
+
// rather than throwing, since a thrown error loses them.)
|
|
60
|
+
pi.on("tool_result", async (event) => {
|
|
61
|
+
const run = event.details as RunResult | undefined;
|
|
62
|
+
if (event.toolName === "code" && run && runFailed(run)) return { isError: true };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pi.registerTool({
|
|
66
|
+
name: "code",
|
|
67
|
+
label: "Code",
|
|
68
|
+
description: codeDescription(graphAvailable),
|
|
69
|
+
promptSnippet:
|
|
70
|
+
"Make multi-file, repetitive or rename/move changes with one Bun program; a small change to one file is quicker as a direct edit. Run verification separately afterward",
|
|
71
|
+
|
|
72
|
+
parameters: Type.Object({
|
|
73
|
+
title: Type.String({ description: "A few words describing the change, shown to the user" }),
|
|
74
|
+
program: Type.String({ description: "TypeScript program run with Bun (top-level await allowed)" }),
|
|
75
|
+
cwd: Type.Optional(
|
|
76
|
+
Type.String({
|
|
77
|
+
description:
|
|
78
|
+
"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.",
|
|
79
|
+
}),
|
|
80
|
+
),
|
|
81
|
+
rollback: Type.Optional(
|
|
82
|
+
StringEnum(["all", "file"] as const, {
|
|
83
|
+
description:
|
|
84
|
+
'On failure: "file" (default) rolls back failed or interrupted file edits and retains the others; "all" applies nothing',
|
|
85
|
+
}),
|
|
86
|
+
),
|
|
87
|
+
timeout: Type.Optional(
|
|
88
|
+
Type.Number({
|
|
89
|
+
description: "Seconds of program time before it is killed (default 2); time inside helpers is excluded",
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
}),
|
|
93
|
+
|
|
94
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
95
|
+
// While it runs, show how long it's been going and the latest reported step.
|
|
96
|
+
const startedAt = Date.now();
|
|
97
|
+
let latest: string | undefined;
|
|
98
|
+
const progress = setInterval(() => {
|
|
99
|
+
const elapsed = `${((Date.now() - startedAt) / 1000).toFixed(1)} s`;
|
|
100
|
+
onUpdate?.({
|
|
101
|
+
content: [{ type: "text", text: "running" }],
|
|
102
|
+
details: { progress: latest ? `${elapsed} · ${latest}` : elapsed },
|
|
103
|
+
});
|
|
104
|
+
}, 500);
|
|
105
|
+
|
|
106
|
+
let result: RunResult;
|
|
107
|
+
try {
|
|
108
|
+
result = await runWithBun(
|
|
109
|
+
{
|
|
110
|
+
cwd: path.resolve(ctx.cwd, params.cwd ?? "."),
|
|
111
|
+
program: params.program,
|
|
112
|
+
timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
113
|
+
rollback: params.rollback ?? "file",
|
|
114
|
+
graph: graphAvailable,
|
|
115
|
+
},
|
|
116
|
+
signal,
|
|
117
|
+
(step) => (latest = step),
|
|
118
|
+
);
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (!(error instanceof RunnerError)) throw error;
|
|
121
|
+
return {
|
|
122
|
+
isError: true,
|
|
123
|
+
content: [{ type: "text" as const, text: [error.message, ...diagnosticLines(error.diagnostics)].join("\n") }],
|
|
124
|
+
details: { infrastructureError: error.message, diagnostics: error.diagnostics },
|
|
125
|
+
};
|
|
126
|
+
} finally {
|
|
127
|
+
clearInterval(progress);
|
|
128
|
+
}
|
|
129
|
+
return {
|
|
130
|
+
content: [
|
|
131
|
+
{
|
|
132
|
+
type: "text",
|
|
133
|
+
text: textForModel(result, { name: `pi-shorthand-${toolCallId}`, truncateHead, truncateTail }),
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
details: result,
|
|
137
|
+
};
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
renderCall(args, theme) {
|
|
141
|
+
return new Text(callLine(args, theme), 0, 0);
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
renderResult(result, options, theme) {
|
|
145
|
+
return renderCodeResult(result, options, theme);
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function renderCodeResult(
|
|
151
|
+
result: { content: readonly unknown[]; details?: unknown },
|
|
152
|
+
{ expanded, isPartial }: { expanded: boolean; isPartial: boolean },
|
|
153
|
+
theme: Theme,
|
|
154
|
+
): Text {
|
|
155
|
+
if (isPartial) {
|
|
156
|
+
const progress = (result.details as { progress?: string } | undefined)?.progress;
|
|
157
|
+
return new Text(theme.fg("muted", progress ? `running… ${progress}` : "running…"), 0, 0);
|
|
158
|
+
}
|
|
159
|
+
if (result.details && typeof result.details === "object" && "infrastructureError" in result.details) {
|
|
160
|
+
const failure = result.details as { infrastructureError: string; diagnostics: Diagnostics };
|
|
161
|
+
return new Text(
|
|
162
|
+
[
|
|
163
|
+
theme.fg("error", failure.infrastructureError),
|
|
164
|
+
"",
|
|
165
|
+
...diagnosticLines(failure.diagnostics).map((line) => theme.fg("muted", line)),
|
|
166
|
+
].join("\n"),
|
|
167
|
+
0,
|
|
168
|
+
0,
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const run = result.details as RunResult | undefined;
|
|
172
|
+
if (!run) return new Text(theme.fg("error", unstructuredResultText(result.content)), 0, 0);
|
|
173
|
+
return new Text(resultLines(run, expanded, theme).join("\n"), 0, 0);
|
|
174
|
+
}
|
package/agentfs-database.ts
DELETED
|
@@ -1,141 +0,0 @@
|
|
|
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
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
/** Types for programs executed by the code tool. Type-only: this does not install runtime globals. */
|
|
2
|
-
import type { ShorthandGlobals } from "./prelude.ts";
|
|
3
|
-
|
|
4
|
-
declare global {
|
|
5
|
-
const $: ShorthandGlobals["$"];
|
|
6
|
-
const edit: ShorthandGlobals["edit"];
|
|
7
|
-
const glob: ShorthandGlobals["glob"];
|
|
8
|
-
const grep: ShorthandGlobals["grep"];
|
|
9
|
-
const sg: ShorthandGlobals["sg"];
|
|
10
|
-
const grit: ShorthandGlobals["grit"];
|
|
11
|
-
const refactor: ShorthandGlobals["refactor"];
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
export type { RewriteResult } from "./prelude.ts";
|
|
15
|
-
export type { ShorthandGlobals };
|