pi-subagents 0.34.0 → 0.35.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/CHANGELOG.md +78 -9
- package/README.md +213 -32
- package/index.ts +1 -0
- package/install.mjs +1 -1
- package/package.json +23 -8
- package/prompts/review-loop.md +3 -1
- package/skills/pi-subagents/SKILL.md +87 -25
- package/src/agents/agent-management.ts +82 -15
- package/src/agents/agent-serializer.ts +19 -0
- package/src/agents/agents.ts +91 -49
- package/src/agents/frontmatter.ts +67 -13
- package/src/agents/skills.ts +25 -12
- package/src/api/background-work.ts +197 -0
- package/src/api/delegation.ts +158 -0
- package/src/extension/chain-validation.ts +165 -0
- package/src/extension/doctor.ts +15 -0
- package/src/extension/fanout-child.ts +3 -1
- package/src/extension/index.ts +65 -124
- package/src/extension/rpc.ts +10 -2
- package/src/extension/schemas.ts +18 -14
- package/src/extension/steering-notices.ts +35 -0
- package/src/extension/tool-description.ts +20 -9
- package/src/intercom/intercom-bridge.ts +3 -2
- package/src/intercom/native-supervisor-channel.ts +9 -1
- package/src/intercom/result-intercom.ts +4 -0
- package/src/runs/background/async-execution.ts +293 -44
- package/src/runs/background/async-job-tracker.ts +56 -9
- package/src/runs/background/async-resume.ts +159 -52
- package/src/runs/background/async-status.ts +25 -18
- package/src/runs/background/auto-drain.ts +67 -0
- package/src/runs/background/chain-root-attachment.ts +16 -8
- package/src/runs/background/control-channel.ts +260 -13
- package/src/runs/background/fleet-view.ts +23 -2
- package/src/runs/background/notify.ts +79 -10
- package/src/runs/background/result-watcher.ts +12 -9
- package/src/runs/background/run-id-resolver.ts +14 -2
- package/src/runs/background/run-status.ts +23 -15
- package/src/runs/background/scheduled-runs.ts +3 -0
- package/src/runs/background/stale-run-reconciler.ts +32 -10
- package/src/runs/background/steering.ts +237 -0
- package/src/runs/background/subagent-runner.ts +898 -236
- package/src/runs/background/subagent-wait.ts +484 -0
- package/src/runs/background/top-level-async.ts +2 -1
- package/src/runs/background/wait-config.ts +36 -0
- package/src/runs/background/wait-tool.ts +26 -0
- package/src/runs/foreground/async-steering-action.ts +230 -0
- package/src/runs/foreground/chain-clarify.ts +22 -6
- package/src/runs/foreground/chain-execution.ts +50 -32
- package/src/runs/foreground/execution.ts +308 -94
- package/src/runs/foreground/subagent-executor.ts +592 -268
- package/src/runs/shared/acceptance.ts +355 -97
- package/src/runs/shared/child-protocol.ts +121 -0
- package/src/runs/shared/completion-guard.ts +8 -127
- package/src/runs/shared/dynamic-fanout.ts +6 -4
- package/src/runs/shared/model-fallback.ts +36 -0
- package/src/runs/shared/nested-events.ts +9 -4
- package/src/runs/shared/nested-render.ts +4 -1
- package/src/runs/shared/parallel-utils.ts +7 -0
- package/src/runs/shared/pi-args.ts +34 -7
- package/src/runs/shared/pi-spawn.ts +18 -12
- package/src/runs/shared/session-lease.ts +279 -0
- package/src/runs/shared/single-output.ts +61 -6
- package/src/runs/shared/spawn-budget.ts +128 -0
- package/src/runs/shared/subagent-control.ts +10 -6
- package/src/runs/shared/subagent-prompt-runtime.ts +127 -26
- package/src/runs/shared/task-intent.ts +176 -0
- package/src/runs/shared/tool-availability.ts +65 -0
- package/src/runs/shared/turn-budget.ts +49 -4
- package/src/shared/atomic-json.ts +4 -1
- package/src/shared/fork-context.ts +28 -3
- package/src/shared/model-info.ts +7 -4
- package/src/shared/status-format.ts +7 -1
- package/src/shared/types.ts +203 -25
- package/src/shared/utils.ts +35 -7
- package/src/slash/delegation-adapters.ts +457 -0
- package/src/slash/delegation-request.ts +103 -0
- package/src/slash/prompt-template-bridge.ts +167 -344
- package/src/slash/slash-commands.ts +239 -6
- package/src/slash/subagents-admin.ts +428 -0
- package/src/slash/subagents-editor.ts +86 -0
- package/src/tui/fleet.ts +405 -0
- package/src/tui/render.ts +90 -16
- package/src/watchdog/change-signature.ts +127 -0
- package/src/watchdog/child-status.ts +205 -0
- package/src/watchdog/emission-guard.ts +123 -0
- package/src/watchdog/lsp-diagnostics.ts +532 -0
- package/src/watchdog/model-selection.ts +167 -0
- package/src/watchdog/register-child.ts +117 -0
- package/src/watchdog/register-main.ts +433 -0
- package/src/watchdog/render.ts +54 -0
- package/src/watchdog/review.ts +293 -0
- package/src/watchdog/runtime.ts +712 -0
- package/src/watchdog/settings.ts +528 -0
- package/src/watchdog/tool-actions.ts +155 -0
- package/src/watchdog/turn-delta.ts +161 -0
- package/src/watchdog/types.ts +188 -0
- package/src/watchdog/warning-format.ts +73 -0
- package/src/runs/background/wait.ts +0 -394
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { WatchdogSeverity, WatchdogWarning } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export type WatchdogEmissionSuppressionReason = "content-free" | "duplicate" | "max-warnings" | "update-budget";
|
|
4
|
+
|
|
5
|
+
export type WatchdogEmissionDecision = {
|
|
6
|
+
accepted: true;
|
|
7
|
+
identity: string;
|
|
8
|
+
underlyingIdentity: string;
|
|
9
|
+
escalation: boolean;
|
|
10
|
+
} | {
|
|
11
|
+
accepted: false;
|
|
12
|
+
reason: WatchdogEmissionSuppressionReason;
|
|
13
|
+
identity?: string;
|
|
14
|
+
underlyingIdentity?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export interface WatchdogEmissionGuardOptions {
|
|
18
|
+
maxWarnings?: number | null;
|
|
19
|
+
dedupeHistoryLimit?: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const CONTENT_FREE_PHRASES = new Set([
|
|
23
|
+
"stop",
|
|
24
|
+
"done",
|
|
25
|
+
"complete",
|
|
26
|
+
"completed",
|
|
27
|
+
"no issue",
|
|
28
|
+
"no issues",
|
|
29
|
+
"no concern",
|
|
30
|
+
"no concerns",
|
|
31
|
+
"nothing to add",
|
|
32
|
+
"lgtm",
|
|
33
|
+
"looks good",
|
|
34
|
+
"looks good to me",
|
|
35
|
+
"all good",
|
|
36
|
+
"ok",
|
|
37
|
+
"okay",
|
|
38
|
+
"none",
|
|
39
|
+
"n a",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export function normalizeWatchdogEmissionText(value: string): string {
|
|
43
|
+
return value
|
|
44
|
+
.normalize("NFKC")
|
|
45
|
+
.toLowerCase()
|
|
46
|
+
.replace(/[’'`]/g, "")
|
|
47
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
48
|
+
.replace(/\s+/g, " ")
|
|
49
|
+
.trim();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function watchdogWarningUnderlyingIdentity(warning: Pick<WatchdogWarning, "summary" | "evidence">): string {
|
|
53
|
+
return [normalizeWatchdogEmissionText(warning.summary), normalizeWatchdogEmissionText(warning.evidence)].join("\n");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function watchdogWarningIdentity(warning: Pick<WatchdogWarning, "severity" | "summary" | "evidence">): string {
|
|
57
|
+
return [warning.severity, watchdogWarningUnderlyingIdentity(warning)].join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isContentFree(value: string): boolean {
|
|
61
|
+
const normalized = normalizeWatchdogEmissionText(value);
|
|
62
|
+
return !normalized || CONTENT_FREE_PHRASES.has(normalized);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class WatchdogEmissionGuard {
|
|
66
|
+
private maxWarnings: number | null;
|
|
67
|
+
private dedupeHistoryLimit: number;
|
|
68
|
+
private acceptedCount = 0;
|
|
69
|
+
private acceptedByUnderlyingIdentity = new Map<string, WatchdogSeverity>();
|
|
70
|
+
private historyOrder: string[] = [];
|
|
71
|
+
private updateAcceptedUnderlyingIdentity: string | undefined;
|
|
72
|
+
private updateAcceptedSeverity: WatchdogSeverity | undefined;
|
|
73
|
+
|
|
74
|
+
constructor(options: WatchdogEmissionGuardOptions = {}) {
|
|
75
|
+
this.maxWarnings = options.maxWarnings ?? null;
|
|
76
|
+
this.dedupeHistoryLimit = options.dedupeHistoryLimit ?? 200;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
startModelUpdate(): void {
|
|
80
|
+
this.updateAcceptedUnderlyingIdentity = undefined;
|
|
81
|
+
this.updateAcceptedSeverity = undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
reset(): void {
|
|
85
|
+
this.acceptedCount = 0;
|
|
86
|
+
this.acceptedByUnderlyingIdentity.clear();
|
|
87
|
+
this.historyOrder = [];
|
|
88
|
+
this.startModelUpdate();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
evaluate(warning: WatchdogWarning): WatchdogEmissionDecision {
|
|
92
|
+
if (isContentFree(warning.summary) || isContentFree(warning.evidence) || isContentFree(warning.recommendedAction)) {
|
|
93
|
+
return { accepted: false, reason: "content-free" };
|
|
94
|
+
}
|
|
95
|
+
const underlyingIdentity = watchdogWarningUnderlyingIdentity(warning);
|
|
96
|
+
const identity = watchdogWarningIdentity(warning);
|
|
97
|
+
const priorSeverity = this.acceptedByUnderlyingIdentity.get(underlyingIdentity);
|
|
98
|
+
const escalation = priorSeverity === "concern" && warning.severity === "blocker";
|
|
99
|
+
if (this.updateAcceptedUnderlyingIdentity !== undefined) {
|
|
100
|
+
const updateEscalation = this.updateAcceptedUnderlyingIdentity === underlyingIdentity
|
|
101
|
+
&& this.updateAcceptedSeverity === "concern"
|
|
102
|
+
&& warning.severity === "blocker";
|
|
103
|
+
if (!updateEscalation) return { accepted: false, reason: "update-budget", identity, underlyingIdentity };
|
|
104
|
+
}
|
|
105
|
+
if (priorSeverity !== undefined && !escalation) return { accepted: false, reason: "duplicate", identity, underlyingIdentity };
|
|
106
|
+
if (this.maxWarnings !== null && this.acceptedCount >= this.maxWarnings && !escalation) {
|
|
107
|
+
return { accepted: false, reason: "max-warnings", identity, underlyingIdentity };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
this.acceptedByUnderlyingIdentity.set(underlyingIdentity, warning.severity);
|
|
111
|
+
if (!priorSeverity) {
|
|
112
|
+
this.acceptedCount++;
|
|
113
|
+
this.historyOrder.push(underlyingIdentity);
|
|
114
|
+
}
|
|
115
|
+
while (this.historyOrder.length > this.dedupeHistoryLimit) {
|
|
116
|
+
const stale = this.historyOrder.shift();
|
|
117
|
+
if (stale) this.acceptedByUnderlyingIdentity.delete(stale);
|
|
118
|
+
}
|
|
119
|
+
this.updateAcceptedUnderlyingIdentity = underlyingIdentity;
|
|
120
|
+
this.updateAcceptedSeverity = warning.severity;
|
|
121
|
+
return { accepted: true, identity, underlyingIdentity, escalation };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import * as fs from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import type {
|
|
7
|
+
WatchdogLspConfig,
|
|
8
|
+
WatchdogLspDiagnostic,
|
|
9
|
+
WatchdogLspDiagnosticSeverity,
|
|
10
|
+
WatchdogLspResult,
|
|
11
|
+
WatchdogWarning,
|
|
12
|
+
} from "./types.ts";
|
|
13
|
+
export interface WatchdogLspRequest {
|
|
14
|
+
cwd: string;
|
|
15
|
+
root: string;
|
|
16
|
+
changedPaths: string[];
|
|
17
|
+
config: WatchdogLspConfig;
|
|
18
|
+
signal?: AbortSignal;
|
|
19
|
+
}
|
|
20
|
+
export type WatchdogLspDiagnosticsFunction = (request: WatchdogLspRequest) => Promise<WatchdogLspResult> | WatchdogLspResult;
|
|
21
|
+
interface TargetFile {
|
|
22
|
+
relPath: string;
|
|
23
|
+
absPath: string;
|
|
24
|
+
uri: string;
|
|
25
|
+
languageId: string;
|
|
26
|
+
}
|
|
27
|
+
interface LspCommand {
|
|
28
|
+
command: string;
|
|
29
|
+
args: string[];
|
|
30
|
+
label: string;
|
|
31
|
+
}
|
|
32
|
+
type JsonRpcId = number | string;
|
|
33
|
+
type JsonRpcMessage = {
|
|
34
|
+
jsonrpc?: string;
|
|
35
|
+
id?: JsonRpcId | null;
|
|
36
|
+
method?: string;
|
|
37
|
+
params?: unknown;
|
|
38
|
+
result?: unknown;
|
|
39
|
+
error?: { message?: string; code?: number };
|
|
40
|
+
};
|
|
41
|
+
type LspDiagnostic = {
|
|
42
|
+
range?: {
|
|
43
|
+
start?: { line?: number; character?: number };
|
|
44
|
+
};
|
|
45
|
+
severity?: number;
|
|
46
|
+
code?: string | number;
|
|
47
|
+
source?: string;
|
|
48
|
+
message?: string;
|
|
49
|
+
};
|
|
50
|
+
const TS_JS_EXTENSIONS = new Map<string, string>([
|
|
51
|
+
[".ts", "typescript"],
|
|
52
|
+
[".tsx", "typescriptreact"],
|
|
53
|
+
[".mts", "typescript"],
|
|
54
|
+
[".cts", "typescript"],
|
|
55
|
+
[".js", "javascript"],
|
|
56
|
+
[".jsx", "javascriptreact"],
|
|
57
|
+
[".mjs", "javascript"],
|
|
58
|
+
[".cjs", "javascript"],
|
|
59
|
+
]);
|
|
60
|
+
const PROVIDER_NAME = "typescript-language-server";
|
|
61
|
+
const MAX_MESSAGE_LENGTH = 500;
|
|
62
|
+
const MAX_STDERR_LENGTH = 2_000;
|
|
63
|
+
const SHUTDOWN_TIMEOUT_MS = 250;
|
|
64
|
+
function normalizeRelPath(value: string): string {
|
|
65
|
+
return value.replaceAll(path.sep, "/").replace(/^\.\//, "");
|
|
66
|
+
}
|
|
67
|
+
function isPathInsideRoot(absPath: string, root: string): boolean {
|
|
68
|
+
const rel = path.relative(root, absPath);
|
|
69
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
70
|
+
}
|
|
71
|
+
function languageIdForPath(filePath: string): string | undefined {
|
|
72
|
+
return TS_JS_EXTENSIONS.get(path.extname(filePath).toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function trimDiagnosticMessage(message: string): string {
|
|
76
|
+
const normalized = message.replace(/\s+/g, " ").trim();
|
|
77
|
+
return normalized.length > MAX_MESSAGE_LENGTH ? `${normalized.slice(0, MAX_MESSAGE_LENGTH - 1)}…` : normalized;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function severityFromLsp(value: number | undefined): WatchdogLspDiagnosticSeverity {
|
|
81
|
+
if (value === 1) return "error";
|
|
82
|
+
if (value === 2) return "warning";
|
|
83
|
+
if (value === 3) return "info";
|
|
84
|
+
return "hint";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function pathExecutable(filePath: string): boolean {
|
|
88
|
+
try {
|
|
89
|
+
const stat = fs.statSync(filePath);
|
|
90
|
+
if (!stat.isFile()) return false;
|
|
91
|
+
if (process.platform === "win32") return true;
|
|
92
|
+
fs.accessSync(filePath, fs.constants.X_OK);
|
|
93
|
+
return true;
|
|
94
|
+
} catch {
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function pathExecutableNames(name: string): string[] {
|
|
100
|
+
if (process.platform !== "win32") return [name];
|
|
101
|
+
const extensions = (process.env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").filter(Boolean);
|
|
102
|
+
return [name, ...extensions.map((ext) => `${name}${ext.toLowerCase()}`), ...extensions.map((ext) => `${name}${ext.toUpperCase()}`)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function resolveTypeScriptLanguageServer(root: string): LspCommand | undefined {
|
|
106
|
+
for (const name of pathExecutableNames(PROVIDER_NAME)) {
|
|
107
|
+
const local = path.join(root, "node_modules", ".bin", name);
|
|
108
|
+
if (pathExecutable(local)) return { command: local, args: ["--stdio"], label: `${PROVIDER_NAME} (project)` };
|
|
109
|
+
}
|
|
110
|
+
for (const dir of (process.env.PATH ?? "").split(path.delimiter).filter(Boolean)) {
|
|
111
|
+
for (const name of pathExecutableNames(PROVIDER_NAME)) {
|
|
112
|
+
const candidate = path.join(dir, name);
|
|
113
|
+
if (pathExecutable(candidate)) return { command: candidate, args: ["--stdio"], label: PROVIDER_NAME };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function collectTargetFiles(root: string, changedPaths: string[], maxFiles: number): { targets: TargetFile[]; skippedPaths: string[] } {
|
|
120
|
+
const targets: TargetFile[] = [];
|
|
121
|
+
const skippedPaths: string[] = [];
|
|
122
|
+
for (const changedPath of changedPaths) {
|
|
123
|
+
const relPath = normalizeRelPath(changedPath);
|
|
124
|
+
const absPath = path.resolve(root, relPath);
|
|
125
|
+
const languageId = languageIdForPath(absPath);
|
|
126
|
+
if (!languageId || !isPathInsideRoot(absPath, root)) {
|
|
127
|
+
skippedPaths.push(relPath);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
let stat: fs.Stats;
|
|
131
|
+
try {
|
|
132
|
+
stat = fs.statSync(absPath);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
135
|
+
skippedPaths.push(relPath);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
if (!stat.isFile()) {
|
|
141
|
+
skippedPaths.push(relPath);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (targets.length >= maxFiles) {
|
|
145
|
+
skippedPaths.push(relPath);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
targets.push({ relPath, absPath, uri: pathToFileURL(absPath).href, languageId });
|
|
149
|
+
}
|
|
150
|
+
return { targets, skippedPaths };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function diagnosticIdentity(diagnostic: WatchdogLspDiagnostic): string {
|
|
154
|
+
return createHash("sha256")
|
|
155
|
+
.update([
|
|
156
|
+
diagnostic.path,
|
|
157
|
+
diagnostic.severity,
|
|
158
|
+
diagnostic.source,
|
|
159
|
+
diagnostic.code ?? "",
|
|
160
|
+
diagnostic.message,
|
|
161
|
+
].join("\n"))
|
|
162
|
+
.digest("hex");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export class WatchdogLspDiagnosticsLedger {
|
|
166
|
+
private readonly seen = new Map<string, Set<string>>();
|
|
167
|
+
|
|
168
|
+
reset(): void {
|
|
169
|
+
this.seen.clear();
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
reduce(result: WatchdogLspResult): WatchdogLspResult {
|
|
173
|
+
if (result.status === "disabled" || result.status === "unavailable" || result.status === "failed") return result;
|
|
174
|
+
const currentByPath = new Map<string, Set<string>>();
|
|
175
|
+
const fresh: WatchdogLspDiagnostic[] = [];
|
|
176
|
+
for (const diagnostic of result.diagnostics) {
|
|
177
|
+
const identity = diagnosticIdentity(diagnostic);
|
|
178
|
+
const current = currentByPath.get(diagnostic.path) ?? new Set<string>();
|
|
179
|
+
current.add(identity);
|
|
180
|
+
currentByPath.set(diagnostic.path, current);
|
|
181
|
+
if (!this.seen.get(diagnostic.path)?.has(identity)) fresh.push(diagnostic);
|
|
182
|
+
}
|
|
183
|
+
for (const [filePath, identities] of currentByPath) this.seen.set(filePath, identities);
|
|
184
|
+
if (result.status === "ok") {
|
|
185
|
+
for (const checkedPath of result.checkedPaths) {
|
|
186
|
+
if (!currentByPath.has(checkedPath)) this.seen.delete(checkedPath);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return { ...result, diagnostics: fresh };
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function formatDiagnostic(diagnostic: WatchdogLspDiagnostic): string {
|
|
194
|
+
const code = diagnostic.code ? ` ${diagnostic.code}` : "";
|
|
195
|
+
return `${diagnostic.path}:${diagnostic.line}:${diagnostic.column} ${diagnostic.severity}${code} ${diagnostic.source}: ${diagnostic.message}`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function formatWatchdogLspDiagnosticsBlock(result: WatchdogLspResult): string {
|
|
199
|
+
const actionable = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error" || diagnostic.severity === "warning");
|
|
200
|
+
if (!actionable.length) return "";
|
|
201
|
+
return [
|
|
202
|
+
"LSP diagnostics:",
|
|
203
|
+
...actionable.map((diagnostic) => `- ${formatDiagnostic(diagnostic)}`),
|
|
204
|
+
].join("\n");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function watchdogWarningFromLspDiagnostics(result: WatchdogLspResult): WatchdogWarning | undefined {
|
|
208
|
+
const actionable = result.diagnostics.filter((diagnostic) => diagnostic.severity === "error" || diagnostic.severity === "warning");
|
|
209
|
+
if (!actionable.length) return undefined;
|
|
210
|
+
const errors = actionable.filter((diagnostic) => diagnostic.severity === "error");
|
|
211
|
+
const severity = errors.length ? "blocker" : "concern";
|
|
212
|
+
const primary = errors[0] ?? actionable[0]!;
|
|
213
|
+
const count = errors.length || actionable.length;
|
|
214
|
+
const kind = errors.length ? "error" : "warning";
|
|
215
|
+
const evidence = actionable.slice(0, 5).map(formatDiagnostic).join("\n");
|
|
216
|
+
return {
|
|
217
|
+
severity,
|
|
218
|
+
category: "correctness",
|
|
219
|
+
confidence: "high",
|
|
220
|
+
source: "lsp",
|
|
221
|
+
summary: `LSP found ${count} ${kind}${count === 1 ? "" : "s"} in changed ${count === 1 ? "file" : "files"}.`,
|
|
222
|
+
evidence: evidence || formatDiagnostic(primary),
|
|
223
|
+
recommendedAction: "Fix the reported diagnostics or explain why they are expected before accepting the change.",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, message: string, signal?: AbortSignal): Promise<T> {
|
|
228
|
+
if (signal?.aborted) return Promise.reject(new Error("aborted"));
|
|
229
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
230
|
+
return new Promise((resolve, reject) => {
|
|
231
|
+
const onAbort = () => {
|
|
232
|
+
if (timer) clearTimeout(timer);
|
|
233
|
+
reject(new Error("aborted"));
|
|
234
|
+
};
|
|
235
|
+
timer = setTimeout(() => {
|
|
236
|
+
signal?.removeEventListener("abort", onAbort);
|
|
237
|
+
reject(new Error(message));
|
|
238
|
+
}, timeoutMs);
|
|
239
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
240
|
+
promise.then(
|
|
241
|
+
(value) => {
|
|
242
|
+
if (timer) clearTimeout(timer);
|
|
243
|
+
signal?.removeEventListener("abort", onAbort);
|
|
244
|
+
resolve(value);
|
|
245
|
+
},
|
|
246
|
+
(error: unknown) => {
|
|
247
|
+
if (timer) clearTimeout(timer);
|
|
248
|
+
signal?.removeEventListener("abort", onAbort);
|
|
249
|
+
reject(error);
|
|
250
|
+
},
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
class JsonRpcLspClient {
|
|
256
|
+
private nextId = 1;
|
|
257
|
+
private stdoutBuffer = Buffer.alloc(0);
|
|
258
|
+
private readonly pending = new Map<JsonRpcId, { resolve(value: unknown): void; reject(error: Error): void }>();
|
|
259
|
+
readonly diagnostics = new Map<string, LspDiagnostic[]>();
|
|
260
|
+
private readonly child: ChildProcessWithoutNullStreams;
|
|
261
|
+
private stderr = "";
|
|
262
|
+
private exited = false;
|
|
263
|
+
private readonly exitWaiters: Array<() => void> = [];
|
|
264
|
+
|
|
265
|
+
constructor(child: ChildProcessWithoutNullStreams) {
|
|
266
|
+
this.child = child;
|
|
267
|
+
child.stdout.on("data", (chunk: Buffer) => this.handleStdout(chunk));
|
|
268
|
+
child.stderr.on("data", (chunk: Buffer) => {
|
|
269
|
+
this.stderr = `${this.stderr}${chunk.toString("utf-8")}`.slice(-MAX_STDERR_LENGTH);
|
|
270
|
+
});
|
|
271
|
+
child.on("error", (error) => {
|
|
272
|
+
this.exited = true;
|
|
273
|
+
this.rejectPending(error);
|
|
274
|
+
this.resolveExitWaiters();
|
|
275
|
+
});
|
|
276
|
+
child.on("exit", (code, signal) => {
|
|
277
|
+
this.exited = true;
|
|
278
|
+
this.rejectPending(new Error(`language server exited${code === null ? "" : ` with code ${code}`}${signal ? ` signal ${signal}` : ""}`));
|
|
279
|
+
this.resolveExitWaiters();
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
request(method: string, params: unknown, timeoutMs: number, signal?: AbortSignal): Promise<unknown> {
|
|
284
|
+
const id = this.nextId++;
|
|
285
|
+
const promise = new Promise<unknown>((resolve, reject) => {
|
|
286
|
+
this.pending.set(id, { resolve, reject });
|
|
287
|
+
this.send({ jsonrpc: "2.0", id, method, params });
|
|
288
|
+
});
|
|
289
|
+
return withTimeout(promise, timeoutMs, `${method} timed out`, signal);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
notify(method: string, params: unknown): void {
|
|
293
|
+
this.send({ jsonrpc: "2.0", method, params });
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async shutdown(): Promise<void> {
|
|
297
|
+
if (this.exited) return;
|
|
298
|
+
try {
|
|
299
|
+
await this.request("shutdown", null, SHUTDOWN_TIMEOUT_MS);
|
|
300
|
+
this.notify("exit", null);
|
|
301
|
+
} catch {
|
|
302
|
+
this.child.kill("SIGTERM");
|
|
303
|
+
}
|
|
304
|
+
await this.waitForExit(SHUTDOWN_TIMEOUT_MS);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
kill(): void {
|
|
308
|
+
if (!this.exited) this.child.kill("SIGTERM");
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
stderrTail(): string {
|
|
312
|
+
return this.stderr.trim();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private send(payload: JsonRpcMessage): void {
|
|
316
|
+
if (this.exited) throw new Error("language server already exited");
|
|
317
|
+
const body = JSON.stringify(payload);
|
|
318
|
+
this.child.stdin.write(`Content-Length: ${Buffer.byteLength(body, "utf-8")}\r\n\r\n${body}`);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private handleStdout(chunk: Buffer): void {
|
|
322
|
+
this.stdoutBuffer = Buffer.concat([this.stdoutBuffer, chunk]);
|
|
323
|
+
while (true) {
|
|
324
|
+
const headerEnd = this.stdoutBuffer.indexOf("\r\n\r\n");
|
|
325
|
+
if (headerEnd === -1) return;
|
|
326
|
+
const header = this.stdoutBuffer.slice(0, headerEnd).toString("utf-8");
|
|
327
|
+
const match = header.match(/content-length:\s*(\d+)/i);
|
|
328
|
+
if (!match) {
|
|
329
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(headerEnd + 4);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const length = Number(match[1]);
|
|
333
|
+
const bodyStart = headerEnd + 4;
|
|
334
|
+
const bodyEnd = bodyStart + length;
|
|
335
|
+
if (this.stdoutBuffer.length < bodyEnd) return;
|
|
336
|
+
const body = this.stdoutBuffer.slice(bodyStart, bodyEnd).toString("utf-8");
|
|
337
|
+
this.stdoutBuffer = this.stdoutBuffer.slice(bodyEnd);
|
|
338
|
+
try {
|
|
339
|
+
this.handleMessage(JSON.parse(body) as JsonRpcMessage);
|
|
340
|
+
} catch (error) {
|
|
341
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
342
|
+
this.failProtocol(new Error(`Invalid LSP JSON-RPC response: ${message}`));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private handleMessage(message: JsonRpcMessage): void {
|
|
349
|
+
if (message.method === "textDocument/publishDiagnostics") {
|
|
350
|
+
const params = message.params as { uri?: unknown; diagnostics?: unknown } | undefined;
|
|
351
|
+
if (typeof params?.uri === "string" && Array.isArray(params.diagnostics)) {
|
|
352
|
+
this.diagnostics.set(params.uri, params.diagnostics as LspDiagnostic[]);
|
|
353
|
+
}
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (message.id === undefined || message.id === null) return;
|
|
357
|
+
const pending = this.pending.get(message.id);
|
|
358
|
+
if (!pending) return;
|
|
359
|
+
this.pending.delete(message.id);
|
|
360
|
+
if (message.error) {
|
|
361
|
+
pending.reject(new Error(message.error.message || `LSP request ${message.id} failed`));
|
|
362
|
+
} else {
|
|
363
|
+
pending.resolve(message.result);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private failProtocol(error: Error): void {
|
|
368
|
+
if (this.exited) return;
|
|
369
|
+
this.rejectPending(error);
|
|
370
|
+
this.child.kill("SIGTERM");
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private waitForExit(timeoutMs: number): Promise<void> {
|
|
374
|
+
if (this.exited) return Promise.resolve();
|
|
375
|
+
return new Promise((resolve) => {
|
|
376
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
377
|
+
this.exitWaiters.push(() => {
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
resolve();
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
private resolveExitWaiters(): void {
|
|
385
|
+
for (const resolve of this.exitWaiters.splice(0)) resolve();
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
private rejectPending(error: Error): void {
|
|
389
|
+
for (const pending of this.pending.values()) pending.reject(error);
|
|
390
|
+
this.pending.clear();
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function convertDiagnostics(target: TargetFile, diagnostics: LspDiagnostic[]): WatchdogLspDiagnostic[] {
|
|
395
|
+
return diagnostics
|
|
396
|
+
.filter((diagnostic) => typeof diagnostic.message === "string" && diagnostic.range?.start)
|
|
397
|
+
.map((diagnostic) => ({
|
|
398
|
+
path: target.relPath,
|
|
399
|
+
line: Math.max(1, (diagnostic.range?.start?.line ?? 0) + 1),
|
|
400
|
+
column: Math.max(1, (diagnostic.range?.start?.character ?? 0) + 1),
|
|
401
|
+
severity: severityFromLsp(diagnostic.severity),
|
|
402
|
+
source: diagnostic.source || PROVIDER_NAME,
|
|
403
|
+
...(diagnostic.code !== undefined ? { code: String(diagnostic.code) } : {}),
|
|
404
|
+
message: trimDiagnosticMessage(diagnostic.message ?? ""),
|
|
405
|
+
}));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function initializeParams(root: string): unknown {
|
|
409
|
+
const rootUri = pathToFileURL(root).href;
|
|
410
|
+
return {
|
|
411
|
+
processId: process.pid,
|
|
412
|
+
rootUri,
|
|
413
|
+
capabilities: {
|
|
414
|
+
textDocument: {
|
|
415
|
+
publishDiagnostics: { relatedInformation: false, versionSupport: true },
|
|
416
|
+
},
|
|
417
|
+
workspace: { configuration: false, workspaceFolders: true },
|
|
418
|
+
},
|
|
419
|
+
workspaceFolders: [{ uri: rootUri, name: path.basename(root) || "workspace" }],
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function waitForDiagnostics(client: JsonRpcLspClient, targets: TargetFile[], timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
424
|
+
const started = Date.now();
|
|
425
|
+
while (!signal?.aborted && Date.now() - started < timeoutMs) {
|
|
426
|
+
if (targets.every((target) => client.diagnostics.has(target.uri))) return true;
|
|
427
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(50, Math.max(1, timeoutMs - (Date.now() - started)))));
|
|
428
|
+
}
|
|
429
|
+
return targets.every((target) => client.diagnostics.has(target.uri));
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
async function collectWithTypeScriptLanguageServer(input: {
|
|
433
|
+
root: string;
|
|
434
|
+
targets: TargetFile[];
|
|
435
|
+
skippedPaths: string[];
|
|
436
|
+
command: LspCommand;
|
|
437
|
+
config: WatchdogLspConfig;
|
|
438
|
+
signal?: AbortSignal;
|
|
439
|
+
}): Promise<WatchdogLspResult> {
|
|
440
|
+
const started = Date.now();
|
|
441
|
+
const child = spawn(input.command.command, input.command.args, {
|
|
442
|
+
cwd: input.root,
|
|
443
|
+
stdio: "pipe",
|
|
444
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
445
|
+
shell: process.platform === "win32" && /\.(cmd|bat)$/i.test(input.command.command),
|
|
446
|
+
});
|
|
447
|
+
const client = new JsonRpcLspClient(child);
|
|
448
|
+
const remaining = () => Math.max(1, input.config.timeoutMs - (Date.now() - started));
|
|
449
|
+
const abort = () => client.kill();
|
|
450
|
+
input.signal?.addEventListener("abort", abort, { once: true });
|
|
451
|
+
try {
|
|
452
|
+
await client.request("initialize", initializeParams(input.root), remaining(), input.signal);
|
|
453
|
+
client.notify("initialized", {});
|
|
454
|
+
for (const target of input.targets) {
|
|
455
|
+
const text = fs.readFileSync(target.absPath, "utf-8");
|
|
456
|
+
client.notify("textDocument/didOpen", {
|
|
457
|
+
textDocument: { uri: target.uri, languageId: target.languageId, version: 1, text },
|
|
458
|
+
});
|
|
459
|
+
client.notify("textDocument/didSave", {
|
|
460
|
+
textDocument: { uri: target.uri },
|
|
461
|
+
text,
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
const complete = await waitForDiagnostics(client, input.targets, remaining(), input.signal);
|
|
465
|
+
const diagnostics = input.targets
|
|
466
|
+
.flatMap((target) => convertDiagnostics(target, client.diagnostics.get(target.uri) ?? []))
|
|
467
|
+
.slice(0, input.config.maxDiagnostics);
|
|
468
|
+
return {
|
|
469
|
+
status: complete ? "ok" : "timeout",
|
|
470
|
+
provider: input.command.label,
|
|
471
|
+
checkedPaths: input.targets.map((target) => target.relPath),
|
|
472
|
+
skippedPaths: input.skippedPaths,
|
|
473
|
+
diagnostics,
|
|
474
|
+
...(complete ? {} : { message: `Timed out waiting ${input.config.timeoutMs}ms for fresh LSP diagnostics.` }),
|
|
475
|
+
};
|
|
476
|
+
} catch (error) {
|
|
477
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
478
|
+
const timedOut = message.includes("timed out") || message === "aborted";
|
|
479
|
+
const stderr = client.stderrTail();
|
|
480
|
+
return {
|
|
481
|
+
status: timedOut ? "timeout" : "failed",
|
|
482
|
+
provider: input.command.label,
|
|
483
|
+
checkedPaths: input.targets.map((target) => target.relPath),
|
|
484
|
+
skippedPaths: input.skippedPaths,
|
|
485
|
+
diagnostics: [],
|
|
486
|
+
message: stderr ? `${message}; ${stderr}` : message,
|
|
487
|
+
};
|
|
488
|
+
} finally {
|
|
489
|
+
input.signal?.removeEventListener("abort", abort);
|
|
490
|
+
await client.shutdown();
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export async function collectWatchdogLspDiagnostics(request: WatchdogLspRequest): Promise<WatchdogLspResult> {
|
|
495
|
+
if (!request.config.enabled) {
|
|
496
|
+
return { status: "disabled", checkedPaths: [], skippedPaths: [], diagnostics: [] };
|
|
497
|
+
}
|
|
498
|
+
const root = path.resolve(request.root || request.cwd);
|
|
499
|
+
const { targets, skippedPaths } = collectTargetFiles(root, request.changedPaths, request.config.maxFiles);
|
|
500
|
+
if (!targets.length) {
|
|
501
|
+
return {
|
|
502
|
+
status: "skipped",
|
|
503
|
+
checkedPaths: [],
|
|
504
|
+
skippedPaths,
|
|
505
|
+
diagnostics: [],
|
|
506
|
+
message: "No changed TypeScript or JavaScript files to check.",
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
const command = resolveTypeScriptLanguageServer(root);
|
|
510
|
+
if (!command) {
|
|
511
|
+
return {
|
|
512
|
+
status: "unavailable",
|
|
513
|
+
provider: PROVIDER_NAME,
|
|
514
|
+
checkedPaths: [],
|
|
515
|
+
skippedPaths: [...skippedPaths, ...targets.map((target) => target.relPath)],
|
|
516
|
+
diagnostics: [],
|
|
517
|
+
message: `${PROVIDER_NAME} was not found in project node_modules/.bin or PATH.`,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
return await collectWithTypeScriptLanguageServer({ root, targets, skippedPaths, command, config: request.config, signal: request.signal });
|
|
522
|
+
} catch (error) {
|
|
523
|
+
return {
|
|
524
|
+
status: "failed",
|
|
525
|
+
provider: command.label,
|
|
526
|
+
checkedPaths: targets.map((target) => target.relPath),
|
|
527
|
+
skippedPaths,
|
|
528
|
+
diagnostics: [],
|
|
529
|
+
message: error instanceof Error ? error.message : String(error),
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
}
|