pi-shorthand 0.5.0 → 0.7.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 +4 -1
- package/api.d.ts +1 -1
- package/display.ts +24 -6
- package/format.ts +13 -1
- package/index.ts +29 -11
- package/linux-observer.c +89 -4
- package/lsp-client.ts +3 -0
- package/model-diff.ts +60 -0
- package/move-imports.ts +697 -0
- package/nfs-observer.ts +61 -8
- package/nfs-proxy.ts +2 -2
- package/overlay-linux.ts +2 -2
- package/package.json +1 -1
- package/placement.ts +73 -6
- package/prelude.ts +297 -53
- package/program-clock.ts +45 -0
- package/program-lint.ts +13 -0
- package/runner.ts +65 -10
- package/skills/shorthand/SKILL.md +25 -13
- package/skills/shorthand/advanced-refactors.md +12 -9
- package/typescript-refactors.ts +52 -9
- package/workspace-edit.ts +11 -8
package/README.md
CHANGED
|
@@ -20,9 +20,10 @@ xcode-select --install
|
|
|
20
20
|
curl -fsSL https://agentfs.ai/install | bash
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
On Linux, install bubblewrap 0.
|
|
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
|
+
apt-cache policy bubblewrap
|
|
26
27
|
sudo apt install bubblewrap build-essential lsof
|
|
27
28
|
```
|
|
28
29
|
|
|
@@ -31,3 +32,5 @@ sudo apt install bubblewrap build-essential lsof
|
|
|
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
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.
|
|
35
|
+
|
|
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.
|
package/api.d.ts
CHANGED
|
@@ -8,7 +8,7 @@ declare global {
|
|
|
8
8
|
const grep: ShorthandGlobals["grep"];
|
|
9
9
|
const sg: ShorthandGlobals["sg"];
|
|
10
10
|
const grit: ShorthandGlobals["grit"];
|
|
11
|
-
const
|
|
11
|
+
const refactor: ShorthandGlobals["refactor"];
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
export type { RewriteResult } from "./prelude.ts";
|
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(
|
|
25
|
-
|
|
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
|
}
|
|
@@ -86,7 +93,7 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
|
|
|
86
93
|
for (const section of sections) lines.push("", ...section);
|
|
87
94
|
const timing = timingBreakdown(run);
|
|
88
95
|
if (timing) lines.push("", theme.fg("muted", `timing: ${timing}`));
|
|
89
|
-
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run
|
|
96
|
+
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= timeoutBudgetMs(run) || run.exitCode !== 0)) {
|
|
90
97
|
lines.push(...diagnosticLines(run.diagnostics).map((line) => theme.fg("muted", line)));
|
|
91
98
|
}
|
|
92
99
|
return lines;
|
|
@@ -108,7 +115,7 @@ const TIMING_LABELS: Record<keyof RunTimings, string> = {
|
|
|
108
115
|
|
|
109
116
|
/** Explain calls exceeding the configured program budget, even when no individual phase does. */
|
|
110
117
|
export function timingBreakdown(run: RunResult): string | undefined {
|
|
111
|
-
if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < run
|
|
118
|
+
if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < timeoutBudgetMs(run)) return undefined;
|
|
112
119
|
const significant = Object.entries(run.timings)
|
|
113
120
|
.map(([phase, milliseconds]) => ({
|
|
114
121
|
label: TIMING_LABELS[phase as keyof RunTimings],
|
|
@@ -124,16 +131,27 @@ function formatDuration(milliseconds: number): string {
|
|
|
124
131
|
return milliseconds < 1_000 ? `${milliseconds}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
|
|
125
132
|
}
|
|
126
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
|
+
|
|
127
145
|
/** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
|
|
128
146
|
function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
|
|
129
147
|
const muted = (text: string) => theme.fg("muted", text);
|
|
130
148
|
const took = muted(` · ${(run.durationMs / 1000).toFixed(1)}s`);
|
|
131
|
-
const failure = run.timedOut ? `Program timed out after ${run
|
|
149
|
+
const failure = run.timedOut ? `Program timed out after ${timeoutText(run)}` : "Failed";
|
|
132
150
|
const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
|
|
133
151
|
|
|
134
152
|
if (run.conflicts.length > 0) {
|
|
135
153
|
const program = run.timedOut
|
|
136
|
-
? ` · timed out after ${run
|
|
154
|
+
? ` · timed out after ${timeoutText(run)}`
|
|
137
155
|
: run.exitCode
|
|
138
156
|
? ` · exit ${run.exitCode}`
|
|
139
157
|
: "";
|
package/format.ts
CHANGED
|
@@ -117,14 +117,26 @@ export function formatterFor(file: string, root: string): Command | null {
|
|
|
117
117
|
return null;
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
+
/** Files of one kind share a formatter decision: JS-family files, Python, Go or Rust. */
|
|
121
|
+
function formatterKind(file: string): string {
|
|
122
|
+
const extension = extname(file);
|
|
123
|
+
if (/\.(?:[cm]?[jt]sx?|jsonc?|css|scss|less|html|vue|svelte|mdx?|ya?ml|graphql)$/i.test(extension)) return "js";
|
|
124
|
+
return extension === ".pyi" ? ".py" : extension;
|
|
125
|
+
}
|
|
126
|
+
|
|
120
127
|
export async function formatChanged(
|
|
121
128
|
files: string[],
|
|
122
129
|
root: string,
|
|
123
130
|
): Promise<{ messages: string[]; warnings: string[] }> {
|
|
124
131
|
const groups = new Map<string, { command: Command; files: string[] }>();
|
|
125
132
|
const result = { messages: [] as string[], warnings: [] as string[] };
|
|
133
|
+
// The choice depends only on a file's directory and kind of source, and each lookup checks several
|
|
134
|
+
// config files per ancestor directory, so decide once for files that share both.
|
|
135
|
+
const decided = new Map<string, Command | null>();
|
|
126
136
|
for (const file of files) {
|
|
127
|
-
const
|
|
137
|
+
const place = `${dirname(resolve(root, file))}\0${formatterKind(file)}`;
|
|
138
|
+
if (!decided.has(place)) decided.set(place, formatterFor(file, root));
|
|
139
|
+
const command = decided.get(place)!;
|
|
128
140
|
if (!command) continue;
|
|
129
141
|
const key = JSON.stringify(command);
|
|
130
142
|
const group = groups.get(key) ?? { command, files: [] };
|
package/index.ts
CHANGED
|
@@ -17,11 +17,13 @@ import {
|
|
|
17
17
|
countLines,
|
|
18
18
|
fileMetadataSummary,
|
|
19
19
|
resultLines,
|
|
20
|
+
timeoutBudgetMs,
|
|
20
21
|
timingBreakdown,
|
|
21
22
|
unstructuredResultText,
|
|
22
23
|
} from "./display.ts";
|
|
23
24
|
import type { FileChange, RunOptions, RunResult } from "./runner.ts";
|
|
24
25
|
import { type Diagnostics, completeDiagnostics, diagnosticLines } from "./diagnostics.ts";
|
|
26
|
+
import { modelDiff } from "./model-diff.ts";
|
|
25
27
|
|
|
26
28
|
class RunnerError extends Error {
|
|
27
29
|
constructor(
|
|
@@ -35,7 +37,7 @@ class RunnerError extends Error {
|
|
|
35
37
|
// Runs typically take well under a second. Longer transformations can request more time.
|
|
36
38
|
const DEFAULT_TIMEOUT_SECONDS = 2;
|
|
37
39
|
|
|
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.
|
|
40
|
+
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.
|
|
39
41
|
|
|
40
42
|
Common operations:
|
|
41
43
|
- 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.
|
|
@@ -43,10 +45,11 @@ Common operations:
|
|
|
43
45
|
- sg.rewrite(pattern, replacement, files?) discovers and rewrites matching code; omit files for the working directory. $X captures one node; $$$X captures a sequence.
|
|
44
46
|
- 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.
|
|
45
47
|
- 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
|
|
47
|
-
- await
|
|
48
|
+
- await refactor.rename({ file, symbol, to }) renames one resolved TypeScript symbol across the project without changing unrelated names.
|
|
49
|
+
- await refactor.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
|
|
50
|
+
- await refactor.move({ file, symbol, to }) moves a top-level declaration to another file and updates the imports that follow it.
|
|
48
51
|
|
|
49
|
-
See the shorthand skill for
|
|
52
|
+
See the shorthand skill for renames, moves and call-site migrations. Read its advanced-refactors.md guide only to extract code, move syntax, use GritQL or edit other languages. 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.`;
|
|
50
53
|
|
|
51
54
|
export default function (pi: ExtensionAPI) {
|
|
52
55
|
// A failed run is an error, both for the model and for how Pi shows it. (execute() returns its details
|
|
@@ -65,18 +68,29 @@ export default function (pi: ExtensionAPI) {
|
|
|
65
68
|
name: "code",
|
|
66
69
|
label: "Code",
|
|
67
70
|
description: DESCRIPTION,
|
|
68
|
-
promptSnippet:
|
|
71
|
+
promptSnippet:
|
|
72
|
+
"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",
|
|
69
73
|
|
|
70
74
|
parameters: Type.Object({
|
|
71
75
|
title: Type.String({ description: "A few words describing the change, shown to the user" }),
|
|
72
76
|
program: Type.String({ description: "TypeScript program run with Bun (top-level await allowed)" }),
|
|
77
|
+
cwd: Type.Optional(
|
|
78
|
+
Type.String({
|
|
79
|
+
description:
|
|
80
|
+
"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.",
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
73
83
|
rollback: Type.Optional(
|
|
74
84
|
StringEnum(["all", "file"] as const, {
|
|
75
85
|
description:
|
|
76
86
|
'On failure: "file" (default) rolls back failed or interrupted file edits and retains the others; "all" applies nothing',
|
|
77
87
|
}),
|
|
78
88
|
),
|
|
79
|
-
timeout: Type.Optional(
|
|
89
|
+
timeout: Type.Optional(
|
|
90
|
+
Type.Number({
|
|
91
|
+
description: "Seconds of program time before it is killed (default 2); time inside helpers is excluded",
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
80
94
|
}),
|
|
81
95
|
|
|
82
96
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -95,7 +109,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
95
109
|
try {
|
|
96
110
|
result = await runWithBun(
|
|
97
111
|
{
|
|
98
|
-
cwd: ctx.cwd,
|
|
112
|
+
cwd: path.resolve(ctx.cwd, params.cwd ?? "."),
|
|
99
113
|
program: params.program,
|
|
100
114
|
timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
101
115
|
rollback: params.rollback ?? "file",
|
|
@@ -307,7 +321,7 @@ function textForModel(run: RunResult, toolCallId: string): string {
|
|
|
307
321
|
// On failure the error goes last, where it's easiest to find; on success, the diff does.
|
|
308
322
|
if (run.exitCode === 0 && run.conflicts.length === 0) lines.push(...output, ...diff);
|
|
309
323
|
else lines.push(...diff, ...output);
|
|
310
|
-
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run
|
|
324
|
+
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= timeoutBudgetMs(run) || run.exitCode !== 0))
|
|
311
325
|
lines.push("", ...diagnosticLines(run.diagnostics));
|
|
312
326
|
return lines.join("\n");
|
|
313
327
|
}
|
|
@@ -330,13 +344,17 @@ function diffForModel(run: RunResult, toolCallId: string): string[] {
|
|
|
330
344
|
const diff = run.changes.map((change) => change.patch).join("\n");
|
|
331
345
|
if (!diff) return [];
|
|
332
346
|
|
|
333
|
-
|
|
347
|
+
// A large diff is shown as one example of each distinct change, then cut to Pi's usual limits.
|
|
348
|
+
const shown = modelDiff(run.changes);
|
|
349
|
+
const truncated = truncateHead(shown.text);
|
|
334
350
|
const lines = ["", truncated.content];
|
|
335
|
-
if (truncated.truncated) {
|
|
351
|
+
if (shown.summarized || truncated.truncated) {
|
|
336
352
|
const fullDiffPath = path.join(tmpdir(), `pi-shorthand-${toolCallId}.diff`);
|
|
337
353
|
writeFileSync(fullDiffPath, diff);
|
|
338
354
|
lines.push(
|
|
339
|
-
|
|
355
|
+
truncated.truncated
|
|
356
|
+
? `[diff truncated at ${truncated.outputLines} of ${truncated.totalLines} lines; full diff: ${fullDiffPath}]`
|
|
357
|
+
: `[full diff: ${fullDiffPath}]`,
|
|
340
358
|
);
|
|
341
359
|
}
|
|
342
360
|
return lines;
|
package/linux-observer.c
CHANGED
|
@@ -37,6 +37,7 @@ static const char *repository;
|
|
|
37
37
|
static size_t repository_length;
|
|
38
38
|
static pid_t initial;
|
|
39
39
|
static volatile sig_atomic_t stopping;
|
|
40
|
+
static int unresolved_external_permission;
|
|
40
41
|
|
|
41
42
|
enum observation_kind {
|
|
42
43
|
OBSERVE_CONTENTS = 'C',
|
|
@@ -121,6 +122,61 @@ static void descriptor(pid_t pid, int fd, char result[PATH_MAX]) {
|
|
|
121
122
|
result[length] = 0;
|
|
122
123
|
if (within(result) && strstr(result, " (deleted)")) die("unresolved deleted repository descriptor");
|
|
123
124
|
}
|
|
125
|
+
/* A missing getprocattr hook reports EINVAL. Other errors, including a label
|
|
126
|
+
* larger than our buffer, leave the access context unknown. */
|
|
127
|
+
static int security_label(const char *path, char label[4096], size_t *length) {
|
|
128
|
+
int fd = open(path, O_RDONLY|O_CLOEXEC);
|
|
129
|
+
if (fd < 0) return -1;
|
|
130
|
+
size_t used = 0;
|
|
131
|
+
for (;;) {
|
|
132
|
+
char extra;
|
|
133
|
+
char *destination = used < 4096 ? label + used : &extra;
|
|
134
|
+
size_t available = used < 4096 ? 4096 - used : 1;
|
|
135
|
+
ssize_t count = read(fd, destination, available);
|
|
136
|
+
if (count < 0 && errno == EINTR) continue;
|
|
137
|
+
if (count < 0) {
|
|
138
|
+
int error = errno;
|
|
139
|
+
close(fd);
|
|
140
|
+
return used == 0 && error == EINVAL ? 0 : -1;
|
|
141
|
+
}
|
|
142
|
+
if (!count) { close(fd); *length = used; return 1; }
|
|
143
|
+
if (used == 4096) { close(fd); return -1; }
|
|
144
|
+
used += count;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
static int same_access_context(pid_t pid) {
|
|
148
|
+
char tracee_path[96], tracee[4096], self[4096];
|
|
149
|
+
snprintf(tracee_path, sizeof(tracee_path), "/proc/%d/status", pid);
|
|
150
|
+
FILE *a = fopen(tracee_path, "r"), *b = fopen("/proc/self/status", "r");
|
|
151
|
+
if (!a || !b) { if (a) fclose(a); if (b) fclose(b); return 0; }
|
|
152
|
+
const char *fields[] = { "Uid:", "Gid:", "Groups:", "CapEff:" };
|
|
153
|
+
unsigned matched = 0;
|
|
154
|
+
while (fgets(tracee, sizeof(tracee), a)) {
|
|
155
|
+
for (size_t j = 0; j < sizeof(fields)/sizeof(*fields); j++) {
|
|
156
|
+
if (strncmp(tracee, fields[j], strlen(fields[j]))) continue;
|
|
157
|
+
if (!strchr(tracee, '\n')) { fclose(a); fclose(b); return 0; }
|
|
158
|
+
rewind(b);
|
|
159
|
+
int found = 0;
|
|
160
|
+
while (fgets(self, sizeof(self), b)) {
|
|
161
|
+
if (!strncmp(self, fields[j], strlen(fields[j]))) {
|
|
162
|
+
if (!strchr(self, '\n') || strcmp(tracee, self)) { fclose(a); fclose(b); return 0; }
|
|
163
|
+
found = 1;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
matched += found;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
fclose(a); fclose(b);
|
|
171
|
+
if (matched != sizeof(fields)/sizeof(*fields)) return 0;
|
|
172
|
+
snprintf(tracee_path, sizeof(tracee_path), "/proc/%d/attr/current", pid);
|
|
173
|
+
size_t tracee_length = 0, self_length = 0;
|
|
174
|
+
int tracee_label = security_label(tracee_path, tracee, &tracee_length);
|
|
175
|
+
int self_label = security_label("/proc/self/attr/current", self, &self_length);
|
|
176
|
+
return (tracee_label == 0 && self_label == 0) ||
|
|
177
|
+
(tracee_label == 1 && self_label == 1 && tracee_length == self_length &&
|
|
178
|
+
!memcmp(tracee, self, tracee_length));
|
|
179
|
+
}
|
|
124
180
|
/* Walk symlinks in the tracee's mount view, recording each repository link.
|
|
125
181
|
* Never use realpath(/proc/PID/root/...): that can resolve against the host mount.
|
|
126
182
|
*/
|
|
@@ -182,7 +238,17 @@ static void resolve_name(pid_t pid, int fd, const char *input, int follow, char
|
|
|
182
238
|
snprintf(visible, sizeof(visible), "/proc/%d/root%s", pid, result);
|
|
183
239
|
struct stat info;
|
|
184
240
|
int exists = lstat(visible, &info);
|
|
185
|
-
if (exists < 0 && errno != ENOENT && errno != ENOTDIR)
|
|
241
|
+
if (exists < 0 && errno != ENOENT && errno != ENOTDIR) {
|
|
242
|
+
if ((errno == EACCES || errno == EPERM) && !within(result) && same_access_context(pid)) {
|
|
243
|
+
/* Matching filesystem credentials, capabilities and security
|
|
244
|
+
* label make this external denial apply to the tracee too.
|
|
245
|
+
* Confirm the syscall is denied before resuming it. */
|
|
246
|
+
unresolved_external_permission = 1;
|
|
247
|
+
result[0] = 0;
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
die("cannot inspect tracee path");
|
|
251
|
+
}
|
|
186
252
|
if ((!*rest && !follow) || exists < 0 || !S_ISLNK(info.st_mode)) {
|
|
187
253
|
snprintf(pending, sizeof(pending), "%s", rest);
|
|
188
254
|
continue;
|
|
@@ -353,6 +419,7 @@ struct task {
|
|
|
353
419
|
int initial_stop;
|
|
354
420
|
int registered;
|
|
355
421
|
int waiting;
|
|
422
|
+
int checking_permission;
|
|
356
423
|
struct task *next;
|
|
357
424
|
};
|
|
358
425
|
static struct task *tasks;
|
|
@@ -372,7 +439,8 @@ static struct task *task(pid_t pid) {
|
|
|
372
439
|
* is not completion: retain the live record until wait reports its death or
|
|
373
440
|
* an exec event explicitly identifies the replaced thread ID. */
|
|
374
441
|
static void resume_task(pid_t pid, int signal) {
|
|
375
|
-
|
|
442
|
+
int request = task(pid)->checking_permission ? PTRACE_SYSCALL : PTRACE_CONT;
|
|
443
|
+
if (ptrace(request, pid, 0, signal) && errno != ESRCH) die("trace resume failed");
|
|
376
444
|
}
|
|
377
445
|
static void syscall_entry(pid_t pid, struct ptrace_syscall_info *info) {
|
|
378
446
|
__u64 *a = info->seccomp.args; long nr = info->seccomp.nr;
|
|
@@ -514,6 +582,8 @@ static void exec_event(pid_t pid) {
|
|
|
514
582
|
unsigned long former;
|
|
515
583
|
if (ptrace(PTRACE_GETEVENTMSG, pid, 0, &former)) die("missing exec thread identity");
|
|
516
584
|
struct task *current = task(pid);
|
|
585
|
+
if (current->checking_permission || (former != (unsigned long)pid && task(former)->checking_permission))
|
|
586
|
+
die("unresolved external path was executed");
|
|
517
587
|
if (former != (unsigned long)pid) {
|
|
518
588
|
current->bootstrap = task(former)->bootstrap;
|
|
519
589
|
task(former)->alive = 0;
|
|
@@ -559,7 +629,7 @@ int main(int argc, char **argv) {
|
|
|
559
629
|
int status, exit_code = 125;
|
|
560
630
|
if (waitpid(initial, &status, 0) != initial || !WIFSTOPPED(status)) die("initial trace stop");
|
|
561
631
|
long flags = PTRACE_O_TRACEFORK|PTRACE_O_TRACEVFORK|PTRACE_O_TRACECLONE|PTRACE_O_TRACEEXEC|
|
|
562
|
-
PTRACE_O_TRACESECCOMP|PTRACE_O_EXITKILL;
|
|
632
|
+
PTRACE_O_TRACESECCOMP|PTRACE_O_TRACESYSGOOD|PTRACE_O_EXITKILL;
|
|
563
633
|
if (ptrace(PTRACE_SETOPTIONS, initial, 0, flags)) die("ptrace options");
|
|
564
634
|
task(initial)->bootstrap = BEFORE_BWRAP_EXEC;
|
|
565
635
|
task(initial)->registered = 1;
|
|
@@ -593,7 +663,22 @@ int main(int argc, char **argv) {
|
|
|
593
663
|
struct ptrace_syscall_info info;
|
|
594
664
|
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0 || info.op != PTRACE_SYSCALL_INFO_SECCOMP)
|
|
595
665
|
die("syscall information unavailable");
|
|
596
|
-
|
|
666
|
+
unresolved_external_permission = 0;
|
|
667
|
+
syscall_entry(pid, &info);
|
|
668
|
+
if (unresolved_external_permission) task(pid)->checking_permission = 1;
|
|
669
|
+
signal = 0;
|
|
670
|
+
} else if (signal == (SIGTRAP|0x80) && task(pid)->checking_permission) {
|
|
671
|
+
struct ptrace_syscall_info info;
|
|
672
|
+
if (ptrace(PTRACE_GET_SYSCALL_INFO, pid, sizeof(info), &info) < 0)
|
|
673
|
+
die("unresolved external path result unavailable");
|
|
674
|
+
if (info.op == PTRACE_SYSCALL_INFO_EXIT) {
|
|
675
|
+
if (info.exit.rval != -EACCES && info.exit.rval != -EPERM)
|
|
676
|
+
die("unresolved external path was accessed");
|
|
677
|
+
task(pid)->checking_permission = 0;
|
|
678
|
+
} else if (info.op != PTRACE_SYSCALL_INFO_ENTRY) {
|
|
679
|
+
die("unresolved external path result unavailable");
|
|
680
|
+
}
|
|
681
|
+
signal = 0;
|
|
597
682
|
} else if (event == PTRACE_EVENT_FORK || event == PTRACE_EVENT_VFORK || event == PTRACE_EVENT_CLONE) {
|
|
598
683
|
descendant_event(pid);
|
|
599
684
|
signal = 0;
|
package/lsp-client.ts
CHANGED
|
@@ -87,6 +87,9 @@ async function startTypeScriptServer(root: string): Promise<TypeScriptServer> {
|
|
|
87
87
|
await connection.sendRequest("initialize", {
|
|
88
88
|
processId: process.pid,
|
|
89
89
|
rootUri: pathToFileURL(root).href,
|
|
90
|
+
// Rename without aliases: by default a renamed declaration is re-exported as `new as old`, so importers
|
|
91
|
+
// through a barrel keep the old name. typescript-refactors.ts keeps object literal keys unchanged instead.
|
|
92
|
+
initializationOptions: { userPreferences: { providePrefixAndSuffixTextForRename: false } },
|
|
90
93
|
capabilities: {
|
|
91
94
|
workspace: {
|
|
92
95
|
workspaceEdit: { documentChanges: true },
|
package/model-diff.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The diff the model reads after a run. Small diffs are shown whole. A large diff is usually one kind of
|
|
3
|
+
* change repeated across many files, so it is shown as one example of each kind, with how many files share
|
|
4
|
+
* it; the per-file list and a path to the whole diff come with it. What people see in Pi is rendered from
|
|
5
|
+
* the result's details, not from this text.
|
|
6
|
+
*/
|
|
7
|
+
import type { FileChange } from "./runner.ts";
|
|
8
|
+
|
|
9
|
+
/** Diffs up to this size are shown whole: about 4,000 tokens. */
|
|
10
|
+
export const WHOLE_DIFF_CHARS = 16_000;
|
|
11
|
+
|
|
12
|
+
/** Changed lines, ignoring numbers and string contents, which usually differ between repeated edits. */
|
|
13
|
+
export function changeKind(patch: string): string {
|
|
14
|
+
return patch
|
|
15
|
+
.split("\n")
|
|
16
|
+
.filter((line) => /^[+-]/.test(line) && !/^(?:\+\+\+|---) /.test(line))
|
|
17
|
+
.map((line) =>
|
|
18
|
+
line
|
|
19
|
+
.replace(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|`(?:[^`\\]|\\.)*`/g, '""')
|
|
20
|
+
.replace(/\d+/g, "0")
|
|
21
|
+
.trim(),
|
|
22
|
+
)
|
|
23
|
+
.join("\n");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ModelDiff {
|
|
27
|
+
text: string;
|
|
28
|
+
/** True when some files are represented by an example rather than shown. */
|
|
29
|
+
summarized: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function modelDiff(changes: FileChange[], budget = WHOLE_DIFF_CHARS): ModelDiff {
|
|
33
|
+
const whole = changes.map((change) => change.patch).join("\n");
|
|
34
|
+
if (whole.length <= budget) return { text: whole, summarized: false };
|
|
35
|
+
|
|
36
|
+
const kinds = new Map<string, FileChange[]>();
|
|
37
|
+
for (const change of changes) {
|
|
38
|
+
const kind = `${change.kind}\0${changeKind(change.patch)}`;
|
|
39
|
+
kinds.set(kind, [...(kinds.get(kind) ?? []), change]);
|
|
40
|
+
}
|
|
41
|
+
// Rarer kinds first: an unusual edit is more likely to be the mistake worth seeing.
|
|
42
|
+
const groups = [...kinds.values()].toSorted((a, b) => a.length - b.length);
|
|
43
|
+
const sections: string[] = [];
|
|
44
|
+
let used = 0;
|
|
45
|
+
let omitted = 0;
|
|
46
|
+
for (const [example, ...same] of groups) {
|
|
47
|
+
const section = [
|
|
48
|
+
example!.patch,
|
|
49
|
+
...(same.length ? [`(and ${same.length} more file${same.length === 1 ? "" : "s"} with the same change)`] : []),
|
|
50
|
+
].join("\n");
|
|
51
|
+
if (sections.length > 0 && used + section.length > budget) {
|
|
52
|
+
omitted += 1 + same.length;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
sections.push(section);
|
|
56
|
+
used += section.length;
|
|
57
|
+
}
|
|
58
|
+
const header = `[${changes.length} files changed in ${groups.length} distinct way${groups.length === 1 ? "" : "s"}; one example of each is shown${omitted ? `, except ${omitted} file${omitted === 1 ? "" : "s"} over the size limit` : ""}]`;
|
|
59
|
+
return { text: [header, ...sections].join("\n"), summarized: true };
|
|
60
|
+
}
|