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 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. For Debian and Ubuntu:
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 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. Progress is reported while a program runs, but no run history is written to disk.
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
@@ -8,6 +8,7 @@ declare global {
8
8
  const grep: ShorthandGlobals["grep"];
9
9
  const sg: ShorthandGlobals["sg"];
10
10
  const grit: ShorthandGlobals["grit"];
11
+ const ts: ShorthandGlobals["ts"];
11
12
  }
12
13
 
13
14
  export type { RewriteResult } from "./prelude.ts";
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 ? `Timed out after ${run.timeoutMs / 1000}s` : "Failed";
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 { callLine, countLines, fileMetadataSummary, resultLines, unstructuredResultText } from "./display.ts";
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
- const result = await runWithBun(
76
- {
77
- cwd: ctx.cwd,
78
- program: params.program,
79
- timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
80
- rollback: params.rollback ?? "file",
81
- },
82
- signal,
83
- (step) => (latest = step),
84
- ).finally(() => clearInterval(progress));
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", reject);
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
- const result: RunResult = JSON.parse(stdout);
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 reject(new Error(stderr.trim() || (signal?.aborted ? "Aborted" : `runner exited with ${code}`)));
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