pi-shorthand 0.3.2 → 0.4.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 +9 -1
- package/api.d.ts +1 -0
- package/diagnostics.ts +142 -0
- package/display.ts +41 -3
- package/format.ts +8 -0
- package/index.ts +92 -15
- package/lsp-client.ts +183 -0
- package/overlay-linux.ts +155 -45
- package/package.json +5 -4
- package/prelude.ts +46 -8
- package/runner.ts +135 -20
- package/skills/shorthand/SKILL.md +15 -1
- package/skills/shorthand/advanced-refactors.md +14 -17
- package/typescript-refactors.ts +138 -0
- package/workspace-edit.ts +111 -0
package/README.md
CHANGED
|
@@ -30,4 +30,12 @@ sudo apt install bubblewrap
|
|
|
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.
|
|
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
|
+
|
|
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.
|
package/api.d.ts
CHANGED
package/diagnostics.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
|
|
3
|
+
export interface DiagnosticSpan {
|
|
4
|
+
name: string;
|
|
5
|
+
startMs: number;
|
|
6
|
+
durationMs?: number;
|
|
7
|
+
failed?: boolean;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface Diagnostics {
|
|
11
|
+
spans: DiagnosticSpan[];
|
|
12
|
+
counters: Record<string, number>;
|
|
13
|
+
phase?: string;
|
|
14
|
+
failurePhase?: string;
|
|
15
|
+
runnerMs?: number;
|
|
16
|
+
startupMs?: number;
|
|
17
|
+
responseMs?: number;
|
|
18
|
+
wallMs?: number;
|
|
19
|
+
incomplete?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const context = new AsyncLocalStorage<{
|
|
23
|
+
startedAt: number;
|
|
24
|
+
diagnostics: Diagnostics;
|
|
25
|
+
publish: (diagnostics: Diagnostics) => void;
|
|
26
|
+
phaseSpan?: DiagnosticSpan;
|
|
27
|
+
}>();
|
|
28
|
+
|
|
29
|
+
/** Run-local, bounded metadata only: never record source text, filenames or command output. */
|
|
30
|
+
export async function withDiagnostics<T>(
|
|
31
|
+
operation: () => Promise<T>,
|
|
32
|
+
publish: (diagnostics: Diagnostics) => void,
|
|
33
|
+
): Promise<{ value: T; diagnostics: Diagnostics }> {
|
|
34
|
+
const state = {
|
|
35
|
+
startedAt: performance.now(),
|
|
36
|
+
diagnostics: { spans: [], counters: {} } as Diagnostics,
|
|
37
|
+
publish: (diagnostics: Diagnostics) => {
|
|
38
|
+
try {
|
|
39
|
+
publish(diagnostics);
|
|
40
|
+
} catch {
|
|
41
|
+
/* Diagnostics must not change the operation's outcome. */
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return context.run(state, async () => {
|
|
46
|
+
state.publish(state.diagnostics);
|
|
47
|
+
try {
|
|
48
|
+
return { value: await operation(), diagnostics: state.diagnostics };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
diagnosticFailure();
|
|
51
|
+
throw error;
|
|
52
|
+
} finally {
|
|
53
|
+
const active = context.getStore()!;
|
|
54
|
+
if (active.phaseSpan)
|
|
55
|
+
active.phaseSpan.durationMs = Math.round(performance.now() - state.startedAt - active.phaseSpan.startMs);
|
|
56
|
+
state.diagnostics.runnerMs = Math.round(performance.now() - state.startedAt);
|
|
57
|
+
state.publish(state.diagnostics);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function measure<T>(name: string, operation: () => Promise<T>): Promise<T> {
|
|
63
|
+
const state = context.getStore();
|
|
64
|
+
if (!state) return operation();
|
|
65
|
+
const start = performance.now();
|
|
66
|
+
const span: DiagnosticSpan = { name, startMs: Math.round(start - state.startedAt) };
|
|
67
|
+
state.diagnostics.spans.push(span);
|
|
68
|
+
state.publish(state.diagnostics);
|
|
69
|
+
try {
|
|
70
|
+
return await operation();
|
|
71
|
+
} catch (error) {
|
|
72
|
+
span.failed = true;
|
|
73
|
+
throw error;
|
|
74
|
+
} finally {
|
|
75
|
+
span.durationMs = Math.round(performance.now() - start);
|
|
76
|
+
state.publish(state.diagnostics);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function diagnosticCounter(name: string, value: number, add = false) {
|
|
81
|
+
const state = context.getStore();
|
|
82
|
+
if (!state) return;
|
|
83
|
+
state.diagnostics.counters[name] = value + (add ? (state.diagnostics.counters[name] ?? 0) : 0);
|
|
84
|
+
state.publish(state.diagnostics);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function diagnosticPhase(phase: string) {
|
|
88
|
+
const state = context.getStore();
|
|
89
|
+
if (!state) return;
|
|
90
|
+
const now = Math.round(performance.now() - state.startedAt);
|
|
91
|
+
if (state.phaseSpan) state.phaseSpan.durationMs = now - state.phaseSpan.startMs;
|
|
92
|
+
state.phaseSpan = { name: phase, startMs: now };
|
|
93
|
+
state.diagnostics.spans.push(state.phaseSpan);
|
|
94
|
+
state.diagnostics.phase = phase;
|
|
95
|
+
state.publish(state.diagnostics);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function diagnosticFailure() {
|
|
99
|
+
const state = context.getStore();
|
|
100
|
+
if (!state || state.diagnostics.failurePhase) return;
|
|
101
|
+
state.diagnostics.failurePhase = state.diagnostics.phase;
|
|
102
|
+
if (state.phaseSpan) state.phaseSpan.failed = true;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Spans are nested within runner phases; these figures must not be added to the phase total. */
|
|
106
|
+
const duration = (ms: number) => `${ms}ms`;
|
|
107
|
+
|
|
108
|
+
/** Parent-observed startup includes event delivery; a missing final event means execution is incomplete. */
|
|
109
|
+
export function completeDiagnostics(diagnostics: Diagnostics, wallMs: number, startupMs: number): Diagnostics {
|
|
110
|
+
diagnostics.wallMs = Math.round(wallMs);
|
|
111
|
+
diagnostics.startupMs = Math.round(startupMs);
|
|
112
|
+
if (diagnostics.runnerMs === undefined) {
|
|
113
|
+
diagnostics.incomplete = true;
|
|
114
|
+
diagnostics.runnerMs = Math.max(0, diagnostics.wallMs - diagnostics.startupMs);
|
|
115
|
+
}
|
|
116
|
+
diagnostics.responseMs = Math.max(0, diagnostics.wallMs - diagnostics.startupMs - diagnostics.runnerMs);
|
|
117
|
+
return diagnostics;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function diagnosticLines(diagnostics: Diagnostics): string[] {
|
|
121
|
+
const lines: string[] = [];
|
|
122
|
+
if (diagnostics.wallMs !== undefined) {
|
|
123
|
+
lines.push(
|
|
124
|
+
`wall ${duration(diagnostics.wallMs)} · runner startup/IPC ${duration(diagnostics.startupMs ?? 0)} · runner${diagnostics.incomplete ? " (observed, incomplete)" : ""} ${duration(diagnostics.runnerMs ?? 0)} · response/exit ${duration(diagnostics.responseMs ?? 0)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
if (diagnostics.spans.length) {
|
|
128
|
+
lines.push("phase detail (nested measurements overlap):");
|
|
129
|
+
lines.push(
|
|
130
|
+
...diagnostics.spans
|
|
131
|
+
.filter((span) => span.durationMs !== 0)
|
|
132
|
+
.map(
|
|
133
|
+
(span) =>
|
|
134
|
+
` ${span.name}: ${span.durationMs === undefined ? "incomplete" : duration(span.durationMs)}${span.failed ? " (failed)" : ""}`,
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
const counters = Object.entries(diagnostics.counters);
|
|
139
|
+
if (counters.length) lines.push(counters.map(([name, value]) => `${name}: ${value}`).join(" · "));
|
|
140
|
+
if (diagnostics.failurePhase) lines.push(`failed during: ${diagnostics.failurePhase}`);
|
|
141
|
+
return lines;
|
|
142
|
+
}
|
package/display.ts
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { getLanguageFromPath, highlightCode, keyHint, renderDiff, type Theme } from "@earendil-works/pi-coding-agent";
|
|
11
|
-
import type { FileChange, RunResult } from "./runner.ts";
|
|
11
|
+
import type { FileChange, RunResult, RunTimings } from "./runner.ts";
|
|
12
|
+
import { diagnosticLines } from "./diagnostics.ts";
|
|
12
13
|
|
|
13
14
|
type ToolBackground = "toolSuccessBg" | "toolErrorBg";
|
|
14
15
|
|
|
@@ -21,7 +22,7 @@ const LISTED_FILES = 8; // …showing this many, then "and N more files"
|
|
|
21
22
|
const EXPANDED_DIFF_LINES = 2000; // even expanded, a diff of hundreds of files stops here
|
|
22
23
|
|
|
23
24
|
export function callLine(args: { title?: string; timeout?: number; rollback?: string }, theme: Theme): string {
|
|
24
|
-
const settings = [args.rollback === "all" && "rollback all", args.timeout && `timeout ${args.timeout}s`];
|
|
25
|
+
const settings = [args.rollback === "all" && "rollback all", args.timeout && `program timeout ${args.timeout}s`];
|
|
25
26
|
const suffix = settings.filter(Boolean).join(", ");
|
|
26
27
|
return `${theme.fg("toolTitle", theme.bold("code"))} ${args.title ?? ""}${suffix ? theme.fg("muted", ` (${suffix})`) : ""}`;
|
|
27
28
|
}
|
|
@@ -83,14 +84,51 @@ export function resultLines(run: RunResult, expanded: boolean, theme: Theme): st
|
|
|
83
84
|
if (output && (expanded || !error)) sections.push(outputLines(output, expanded, run.changes.length > 0, theme));
|
|
84
85
|
if (notApplied.length > 0) sections.push(notAppliedLines(notApplied, expanded, theme, background));
|
|
85
86
|
for (const section of sections) lines.push("", ...section);
|
|
87
|
+
const timing = timingBreakdown(run);
|
|
88
|
+
if (timing) lines.push("", theme.fg("muted", `timing: ${timing}`));
|
|
89
|
+
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run.timeoutMs || run.exitCode !== 0)) {
|
|
90
|
+
lines.push(...diagnosticLines(run.diagnostics).map((line) => theme.fg("muted", line)));
|
|
91
|
+
}
|
|
86
92
|
return lines;
|
|
87
93
|
}
|
|
88
94
|
|
|
95
|
+
const TIMING_LABELS: Record<keyof RunTimings, string> = {
|
|
96
|
+
resolveRepositoryMs: "repository",
|
|
97
|
+
waitForLockMs: "lock wait",
|
|
98
|
+
workspaceSetupMs: "workspace setup",
|
|
99
|
+
programMs: "program",
|
|
100
|
+
scanChangesMs: "change scan",
|
|
101
|
+
formatMs: "formatter",
|
|
102
|
+
workspaceCloseMs: "workspace cleanup",
|
|
103
|
+
checkConflictsMs: "conflict check",
|
|
104
|
+
applyMs: "apply",
|
|
105
|
+
renderDiffMs: "diff",
|
|
106
|
+
unattributedMs: "other",
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/** Explain calls exceeding the configured program budget, even when no individual phase does. */
|
|
110
|
+
export function timingBreakdown(run: RunResult): string | undefined {
|
|
111
|
+
if (!run.timings || (run.diagnostics?.wallMs ?? run.durationMs) < run.timeoutMs) return undefined;
|
|
112
|
+
const significant = Object.entries(run.timings)
|
|
113
|
+
.map(([phase, milliseconds]) => ({
|
|
114
|
+
label: TIMING_LABELS[phase as keyof RunTimings],
|
|
115
|
+
milliseconds,
|
|
116
|
+
}))
|
|
117
|
+
.filter(({ milliseconds }) => milliseconds > 0)
|
|
118
|
+
.toSorted((a, b) => b.milliseconds - a.milliseconds);
|
|
119
|
+
if (significant.length === 0) return undefined;
|
|
120
|
+
return significant.map(({ label, milliseconds }) => `${label} ${formatDuration(milliseconds)}`).join(" · ");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatDuration(milliseconds: number): string {
|
|
124
|
+
return milliseconds < 1_000 ? `${milliseconds}ms` : `${(milliseconds / 1_000).toFixed(1)}s`;
|
|
125
|
+
}
|
|
126
|
+
|
|
89
127
|
/** e.g. "✓ Applied 3 files · +6 −6 · 0.6s" or "✕ Failed · rolled back all changes · exit 1 · 0.2s" */
|
|
90
128
|
function verdict(run: RunResult, applied: FileChange[], theme: Theme): string {
|
|
91
129
|
const muted = (text: string) => theme.fg("muted", text);
|
|
92
130
|
const took = muted(` · ${(run.durationMs / 1000).toFixed(1)}s`);
|
|
93
|
-
const failure = run.timedOut ? `
|
|
131
|
+
const failure = run.timedOut ? `Program timed out after ${run.timeoutMs / 1000}s` : "Failed";
|
|
94
132
|
const exit = run.timedOut ? "" : muted(` · exit ${run.exitCode}`);
|
|
95
133
|
|
|
96
134
|
if (run.conflicts.length > 0) {
|
package/format.ts
CHANGED
|
@@ -4,6 +4,14 @@ import { dirname, extname, join, relative, resolve } from "node:path";
|
|
|
4
4
|
|
|
5
5
|
type Command = { name: string; executable: string; args: string[]; cwd: string };
|
|
6
6
|
|
|
7
|
+
/** A conservative filter: configuration and executable discovery still happen inside the sandbox. */
|
|
8
|
+
export function supportsFormatting(file: string): boolean {
|
|
9
|
+
return (
|
|
10
|
+
/\.(?:[cm]?[jt]sx?|jsonc?|css|scss|less|html|vue|svelte|mdx?|ya?ml|graphql)$/i.test(file) ||
|
|
11
|
+
[".py", ".pyi", ".go", ".rs"].includes(extname(file))
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
7
15
|
function text(file: string): string {
|
|
8
16
|
try {
|
|
9
17
|
return readFileSync(file, "utf8");
|
package/index.ts
CHANGED
|
@@ -12,8 +12,25 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
12
12
|
import { type ExtensionAPI, type Theme, truncateHead, truncateTail } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { Text } from "@earendil-works/pi-tui";
|
|
14
14
|
import { Type } from "typebox";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
callLine,
|
|
17
|
+
countLines,
|
|
18
|
+
fileMetadataSummary,
|
|
19
|
+
resultLines,
|
|
20
|
+
timingBreakdown,
|
|
21
|
+
unstructuredResultText,
|
|
22
|
+
} from "./display.ts";
|
|
16
23
|
import type { FileChange, RunOptions, RunResult } from "./runner.ts";
|
|
24
|
+
import { type Diagnostics, completeDiagnostics, diagnosticLines } from "./diagnostics.ts";
|
|
25
|
+
|
|
26
|
+
class RunnerError extends Error {
|
|
27
|
+
constructor(
|
|
28
|
+
message: string,
|
|
29
|
+
readonly diagnostics: Diagnostics,
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
17
34
|
|
|
18
35
|
// Runs typically take well under a second. Longer transformations can request more time.
|
|
19
36
|
const DEFAULT_TIMEOUT_SECONDS = 2;
|
|
@@ -26,6 +43,8 @@ Common operations:
|
|
|
26
43
|
- sg.rewrite(pattern, replacement, files?) discovers and rewrites matching code; omit files for the working directory. $X captures one node; $$$X captures a sequence.
|
|
27
44
|
- sg.one(pattern, files?) selects exactly one match; sg.find returns an array. sg.rewrite also accepts a selected match or array without a file scope.
|
|
28
45
|
- A rewrite callback receives a match and returns text, a native node.replace(text) edit, or null to skip. Return native edits to apply them. Pass selected arrays together for independent edits; select again after changing their file.
|
|
46
|
+
- await ts.rename({ file, symbol, to }) renames one resolved TypeScript symbol across the project without changing unrelated names.
|
|
47
|
+
- await ts.renameFile({ from, to }) moves a TypeScript file and updates module paths that resolve to it.
|
|
29
48
|
|
|
30
49
|
See the shorthand skill for common writes. For extraction, complex rewrites or other languages, read its advanced-refactors.md guide. The default timeout is two seconds; request more for longer programs.`;
|
|
31
50
|
|
|
@@ -72,16 +91,28 @@ export default function (pi: ExtensionAPI) {
|
|
|
72
91
|
});
|
|
73
92
|
}, 500);
|
|
74
93
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
94
|
+
let result: RunResult;
|
|
95
|
+
try {
|
|
96
|
+
result = await runWithBun(
|
|
97
|
+
{
|
|
98
|
+
cwd: ctx.cwd,
|
|
99
|
+
program: params.program,
|
|
100
|
+
timeoutMs: (params.timeout ?? DEFAULT_TIMEOUT_SECONDS) * 1000,
|
|
101
|
+
rollback: params.rollback ?? "file",
|
|
102
|
+
},
|
|
103
|
+
signal,
|
|
104
|
+
(step) => (latest = step),
|
|
105
|
+
);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (!(error instanceof RunnerError)) throw error;
|
|
108
|
+
return {
|
|
109
|
+
isError: true,
|
|
110
|
+
content: [{ type: "text" as const, text: [error.message, ...diagnosticLines(error.diagnostics)].join("\n") }],
|
|
111
|
+
details: { infrastructureError: error.message, diagnostics: error.diagnostics },
|
|
112
|
+
};
|
|
113
|
+
} finally {
|
|
114
|
+
clearInterval(progress);
|
|
115
|
+
}
|
|
85
116
|
return {
|
|
86
117
|
content: [{ type: "text", text: textForModel(result, toolCallId) }],
|
|
87
118
|
details: result,
|
|
@@ -107,6 +138,18 @@ export function renderCodeResult(
|
|
|
107
138
|
const progress = (result.details as { progress?: string } | undefined)?.progress;
|
|
108
139
|
return new Text(theme.fg("muted", progress ? `running… ${progress}` : "running…"), 0, 0);
|
|
109
140
|
}
|
|
141
|
+
if (result.details && typeof result.details === "object" && "infrastructureError" in result.details) {
|
|
142
|
+
const failure = result.details as { infrastructureError: string; diagnostics: Diagnostics };
|
|
143
|
+
return new Text(
|
|
144
|
+
[
|
|
145
|
+
theme.fg("error", failure.infrastructureError),
|
|
146
|
+
"",
|
|
147
|
+
...diagnosticLines(failure.diagnostics).map((line) => theme.fg("muted", line)),
|
|
148
|
+
].join("\n"),
|
|
149
|
+
0,
|
|
150
|
+
0,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
110
153
|
const run = result.details as RunResult | undefined;
|
|
111
154
|
if (!run) return new Text(theme.fg("error", unstructuredResultText(result.content)), 0, 0);
|
|
112
155
|
return new Text(resultLines(run, expanded, theme).join("\n"), 0, 0);
|
|
@@ -122,6 +165,13 @@ export function runWithBun(
|
|
|
122
165
|
signal?: AbortSignal,
|
|
123
166
|
onProgress?: (step: string) => void,
|
|
124
167
|
): Promise<RunResult> {
|
|
168
|
+
const startedAt = performance.now();
|
|
169
|
+
let firstEventAt: number | undefined;
|
|
170
|
+
let diagnostics: Diagnostics = { spans: [], counters: {} };
|
|
171
|
+
const finishDiagnostics = () => {
|
|
172
|
+
const now = performance.now();
|
|
173
|
+
return completeDiagnostics(diagnostics, now - startedAt, (firstEventAt ?? now) - startedAt);
|
|
174
|
+
};
|
|
125
175
|
return new Promise((resolve, reject) => {
|
|
126
176
|
if (signal?.aborted) return reject(new Error("Aborted"));
|
|
127
177
|
const runner = spawn("bun", [path.join(import.meta.dirname, "runner.ts")], {
|
|
@@ -146,14 +196,23 @@ export function runWithBun(
|
|
|
146
196
|
progressBuffer = lines.pop() ?? "";
|
|
147
197
|
for (const line of lines) {
|
|
148
198
|
try {
|
|
149
|
-
const event = JSON.parse(line) as { step?: unknown };
|
|
199
|
+
const event = JSON.parse(line) as { step?: unknown; diagnostics?: Diagnostics };
|
|
200
|
+
firstEventAt ??= performance.now();
|
|
201
|
+
if (event.diagnostics) {
|
|
202
|
+
diagnostics = event.diagnostics;
|
|
203
|
+
const active = diagnostics.spans.findLast((span) => span.durationMs === undefined);
|
|
204
|
+
if (active) onProgress?.(active.name);
|
|
205
|
+
}
|
|
150
206
|
if (typeof event.step === "string") onProgress?.(event.step);
|
|
151
207
|
} catch {
|
|
152
208
|
// Progress is advisory; malformed events do not affect the run.
|
|
153
209
|
}
|
|
154
210
|
}
|
|
155
211
|
});
|
|
156
|
-
runner.on("error",
|
|
212
|
+
runner.on("error", (error) => {
|
|
213
|
+
signal?.removeEventListener("abort", stop);
|
|
214
|
+
reject(new RunnerError(error.message, finishDiagnostics()));
|
|
215
|
+
});
|
|
157
216
|
runner.on("close", (code) => {
|
|
158
217
|
signal?.removeEventListener("abort", stop);
|
|
159
218
|
try {
|
|
@@ -162,10 +221,24 @@ export function runWithBun(
|
|
|
162
221
|
// nothing left
|
|
163
222
|
}
|
|
164
223
|
if (code === 0) {
|
|
165
|
-
|
|
224
|
+
let result: RunResult;
|
|
225
|
+
try {
|
|
226
|
+
result = JSON.parse(stdout);
|
|
227
|
+
} catch {
|
|
228
|
+
reject(new RunnerError("Runner returned invalid JSON", finishDiagnostics()));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
diagnostics = result.diagnostics ?? diagnostics;
|
|
232
|
+
result.diagnostics = finishDiagnostics();
|
|
166
233
|
if (!signal?.aborted || result.applied.length > 0 || result.cleanupWarnings.length > 0) resolve(result);
|
|
167
234
|
else reject(new Error("Aborted"));
|
|
168
|
-
} else
|
|
235
|
+
} else
|
|
236
|
+
reject(
|
|
237
|
+
new RunnerError(
|
|
238
|
+
stderr.trim() || (signal?.aborted ? "Aborted" : `runner exited with ${code}`),
|
|
239
|
+
finishDiagnostics(),
|
|
240
|
+
),
|
|
241
|
+
);
|
|
169
242
|
});
|
|
170
243
|
|
|
171
244
|
runner.stdin.end(JSON.stringify(options));
|
|
@@ -205,6 +278,8 @@ function fileLine(change: FileChange): string {
|
|
|
205
278
|
|
|
206
279
|
function textForModel(run: RunResult, toolCallId: string): string {
|
|
207
280
|
const lines = [summaryLine(run)];
|
|
281
|
+
const timing = timingBreakdown(run);
|
|
282
|
+
if (timing) lines.push(`Timing: ${timing}`);
|
|
208
283
|
|
|
209
284
|
if (run.applied.length === 0 && run.changes.length > 0) {
|
|
210
285
|
lines.push("The real workspace is unchanged. Below is the candidate diff.");
|
|
@@ -232,6 +307,8 @@ function textForModel(run: RunResult, toolCallId: string): string {
|
|
|
232
307
|
// On failure the error goes last, where it's easiest to find; on success, the diff does.
|
|
233
308
|
if (run.exitCode === 0 && run.conflicts.length === 0) lines.push(...output, ...diff);
|
|
234
309
|
else lines.push(...diff, ...output);
|
|
310
|
+
if (run.diagnostics && ((run.diagnostics.wallMs ?? run.durationMs) >= run.timeoutMs || run.exitCode !== 0))
|
|
311
|
+
lines.push("", ...diagnosticLines(run.diagnostics));
|
|
235
312
|
return lines.join("\n");
|
|
236
313
|
}
|
|
237
314
|
|
package/lsp-client.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { readdirSync, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, resolve as resolvePath } from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
createMessageConnection,
|
|
8
|
+
StreamMessageReader,
|
|
9
|
+
StreamMessageWriter,
|
|
10
|
+
type MessageConnection,
|
|
11
|
+
} from "vscode-jsonrpc/node";
|
|
12
|
+
|
|
13
|
+
interface TypeScriptServer {
|
|
14
|
+
root: string;
|
|
15
|
+
process: ChildProcessWithoutNullStreams;
|
|
16
|
+
connection: MessageConnection;
|
|
17
|
+
files: Map<string, string>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
let current: TypeScriptServer | undefined;
|
|
21
|
+
let operations = Promise.resolve();
|
|
22
|
+
process.once("exit", disposeCurrent);
|
|
23
|
+
|
|
24
|
+
export async function withTypeScriptServer<T>(
|
|
25
|
+
root: string,
|
|
26
|
+
use: (connection: MessageConnection) => Promise<T>,
|
|
27
|
+
): Promise<T> {
|
|
28
|
+
const result = operations.then(async () => {
|
|
29
|
+
const projectRoot = realpathSync(root);
|
|
30
|
+
if (current?.root === projectRoot) {
|
|
31
|
+
if (!sameFiles(current.files, projectFiles(projectRoot))) disposeCurrent();
|
|
32
|
+
} else {
|
|
33
|
+
disposeCurrent();
|
|
34
|
+
}
|
|
35
|
+
if (!current) {
|
|
36
|
+
current = await startTypeScriptServer(projectRoot);
|
|
37
|
+
}
|
|
38
|
+
const server = current;
|
|
39
|
+
setReferenced(server, true);
|
|
40
|
+
try {
|
|
41
|
+
return await use(server.connection);
|
|
42
|
+
} finally {
|
|
43
|
+
if (current === server) setReferenced(server, false);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
operations = result.then(
|
|
47
|
+
() => undefined,
|
|
48
|
+
() => undefined,
|
|
49
|
+
);
|
|
50
|
+
return result;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Keep a reused server current; a failed notification only forfeits reuse of that server. */
|
|
54
|
+
export async function notifyTypeScriptServer(
|
|
55
|
+
connection: MessageConnection,
|
|
56
|
+
method: string,
|
|
57
|
+
params: unknown,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
try {
|
|
60
|
+
await connection.sendNotification(method, params);
|
|
61
|
+
} catch {
|
|
62
|
+
if (current?.connection === connection) disposeCurrent();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function recordTypeScriptFiles(connection: MessageConnection, files: string[]): void {
|
|
67
|
+
if (current?.connection !== connection) return;
|
|
68
|
+
for (const file of files) {
|
|
69
|
+
const value = fingerprint(file);
|
|
70
|
+
if (value) current.files.set(file, value);
|
|
71
|
+
else current.files.delete(file);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function startTypeScriptServer(root: string): Promise<TypeScriptServer> {
|
|
76
|
+
const server = spawn(typeScriptExecutable(), ["--lsp", "--stdio"], { cwd: root, env: process.env });
|
|
77
|
+
await new Promise<void>((ready, reject) => {
|
|
78
|
+
server.once("spawn", ready);
|
|
79
|
+
server.once("error", reject);
|
|
80
|
+
});
|
|
81
|
+
const connection = createMessageConnection(
|
|
82
|
+
new StreamMessageReader(server.stdout),
|
|
83
|
+
new StreamMessageWriter(server.stdin),
|
|
84
|
+
);
|
|
85
|
+
connection.listen();
|
|
86
|
+
try {
|
|
87
|
+
await connection.sendRequest("initialize", {
|
|
88
|
+
processId: process.pid,
|
|
89
|
+
rootUri: pathToFileURL(root).href,
|
|
90
|
+
capabilities: {
|
|
91
|
+
workspace: {
|
|
92
|
+
workspaceEdit: { documentChanges: true },
|
|
93
|
+
fileOperations: { didRename: true, willRename: true },
|
|
94
|
+
},
|
|
95
|
+
textDocument: { rename: { prepareSupport: true } },
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
await connection.sendNotification("initialized", {});
|
|
99
|
+
} catch (error) {
|
|
100
|
+
connection.dispose();
|
|
101
|
+
if (server.exitCode === null) server.kill("SIGKILL");
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
server.stderr.resume();
|
|
105
|
+
const started = { root, process: server, connection, files: projectFiles(root) };
|
|
106
|
+
server.once("exit", () => {
|
|
107
|
+
if (current?.process !== server) return;
|
|
108
|
+
connection.dispose();
|
|
109
|
+
current = undefined;
|
|
110
|
+
});
|
|
111
|
+
setReferenced(started, false);
|
|
112
|
+
return started;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function sameFiles(left: Map<string, string>, right: Map<string, string>): boolean {
|
|
116
|
+
return left.size === right.size && [...left].every(([file, signature]) => right.get(file) === signature);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function projectFiles(root: string): Map<string, string> {
|
|
120
|
+
// The runner only applies Git-visible files, so ignored build output cannot make reuse stale.
|
|
121
|
+
const listed = spawnSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
|
|
122
|
+
cwd: root,
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
});
|
|
125
|
+
if (listed.status === 0) {
|
|
126
|
+
return new Map(
|
|
127
|
+
listed.stdout
|
|
128
|
+
.split("\0")
|
|
129
|
+
.filter((file) => file && /\.(?:[cm]?[jt]sx?|json)$/.test(file))
|
|
130
|
+
.flatMap((file) => {
|
|
131
|
+
const absolute = resolvePath(root, file);
|
|
132
|
+
const value = fingerprint(absolute);
|
|
133
|
+
return value ? [[absolute, value] as const] : [];
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const files = new Map<string, string>();
|
|
139
|
+
const visit = (directory: string) => {
|
|
140
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
141
|
+
if (entry.name === ".git" || entry.name === "node_modules") continue;
|
|
142
|
+
const file = resolvePath(directory, entry.name);
|
|
143
|
+
if (entry.isDirectory()) visit(file);
|
|
144
|
+
else if (entry.isFile() && /\.(?:[cm]?[jt]sx?|json)$/.test(entry.name)) {
|
|
145
|
+
files.set(file, fingerprint(file)!);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
visit(root);
|
|
150
|
+
return files;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function fingerprint(file: string): string | undefined {
|
|
154
|
+
const stats = statSync(file, { bigint: true, throwIfNoEntry: false });
|
|
155
|
+
return stats?.isFile() ? `${stats.mtimeNs}:${stats.size}` : undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function setReferenced(server: TypeScriptServer, referenced: boolean): void {
|
|
159
|
+
const method = referenced ? "ref" : "unref";
|
|
160
|
+
server.process[method]();
|
|
161
|
+
for (const stream of [server.process.stdin, server.process.stdout, server.process.stderr])
|
|
162
|
+
(stream as unknown as Record<typeof method, () => void>)[method]?.();
|
|
163
|
+
process.removeListener("beforeExit", disposeCurrent);
|
|
164
|
+
if (!referenced) process.once("beforeExit", disposeCurrent);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function disposeCurrent(): void {
|
|
168
|
+
if (!current) return;
|
|
169
|
+
const { connection, process: server } = current;
|
|
170
|
+
current = undefined;
|
|
171
|
+
connection.dispose();
|
|
172
|
+
if (server.exitCode === null) server.kill("SIGKILL");
|
|
173
|
+
server.unref();
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function typeScriptExecutable(): string {
|
|
177
|
+
const require = createRequire(import.meta.url);
|
|
178
|
+
const typescriptPackage = require.resolve("typescript/package.json");
|
|
179
|
+
const requireTypeScriptDependency = createRequire(typescriptPackage);
|
|
180
|
+
const nativePackage = `@typescript/typescript-${process.platform}-${process.arch}/package.json`;
|
|
181
|
+
const packageFile = requireTypeScriptDependency.resolve(nativePackage);
|
|
182
|
+
return resolvePath(dirname(packageFile), "lib", process.platform === "win32" ? "tsc.exe" : "tsc");
|
|
183
|
+
}
|