@plannotator/pi-extension 0.21.1 → 0.21.2
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/generated/agent-jobs.ts +24 -0
- package/generated/agent-review-message.ts +29 -8
- package/generated/ai/providers/codex-sdk.ts +37 -2
- package/generated/claude-review.ts +46 -44
- package/generated/codex-review.ts +49 -19
- package/generated/external-annotation.ts +78 -20
- package/generated/marker-review.ts +824 -0
- package/generated/review-findings.ts +75 -0
- package/generated/review-profiles.ts +144 -0
- package/generated/review-skill-loader.ts +467 -0
- package/package.json +1 -1
- package/plannotator.html +3 -3
- package/review-editor.html +200 -183
- package/server/agent-jobs.ts +161 -34
- package/server/serverReview.ts +203 -32
package/server/agent-jobs.ts
CHANGED
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
11
|
-
import { spawn, execFileSync, type ChildProcess } from "node:child_process";
|
|
11
|
+
import { spawn, execFileSync, execFile, type ChildProcess } from "node:child_process";
|
|
12
|
+
import { promisify } from "node:util";
|
|
13
|
+
|
|
14
|
+
const execFileAsync = promisify(execFile);
|
|
12
15
|
import {
|
|
13
16
|
type AgentJobInfo,
|
|
14
17
|
type AgentJobEvent,
|
|
@@ -21,6 +24,12 @@ import {
|
|
|
21
24
|
AGENT_HEARTBEAT_INTERVAL_MS,
|
|
22
25
|
} from "../generated/agent-jobs.js";
|
|
23
26
|
import { formatClaudeLogEvent } from "../generated/claude-review.js";
|
|
27
|
+
import {
|
|
28
|
+
MARKER_ENGINES,
|
|
29
|
+
formatMarkerLogEvent,
|
|
30
|
+
type MarkerEngine,
|
|
31
|
+
type MarkerModel,
|
|
32
|
+
} from "../generated/marker-review.js";
|
|
24
33
|
import { json, parseBody } from "./helpers.js";
|
|
25
34
|
|
|
26
35
|
// ---------------------------------------------------------------------------
|
|
@@ -32,6 +41,16 @@ const JOBS = `${BASE}/jobs`;
|
|
|
32
41
|
const JOBS_STREAM = `${JOBS}/stream`;
|
|
33
42
|
const CAPABILITIES = `${BASE}/capabilities`;
|
|
34
43
|
|
|
44
|
+
// Providers whose command is owned by the server. Client-supplied argv is never
|
|
45
|
+
// spawned for these — buildCommand must produce the command or the launch fails.
|
|
46
|
+
const SERVER_BUILT_PROVIDERS: ReadonlySet<string> = new Set([
|
|
47
|
+
"claude",
|
|
48
|
+
"codex",
|
|
49
|
+
"tour",
|
|
50
|
+
"cursor",
|
|
51
|
+
"opencode",
|
|
52
|
+
]);
|
|
53
|
+
|
|
35
54
|
// ---------------------------------------------------------------------------
|
|
36
55
|
// which() helper for Node.js
|
|
37
56
|
// ---------------------------------------------------------------------------
|
|
@@ -54,7 +73,7 @@ export interface AgentJobHandlerOptions {
|
|
|
54
73
|
mode: "plan" | "review" | "annotate";
|
|
55
74
|
getServerUrl: () => string;
|
|
56
75
|
getCwd: () => string;
|
|
57
|
-
/**
|
|
76
|
+
/** Build the command server-side for a given provider. */
|
|
58
77
|
buildCommand?: (provider: string, config?: Record<string, unknown>) => Promise<{
|
|
59
78
|
command: string[];
|
|
60
79
|
outputPath?: string;
|
|
@@ -79,11 +98,36 @@ export interface AgentJobHandlerOptions {
|
|
|
79
98
|
diffScope?: string;
|
|
80
99
|
/** Diff context snapshot at launch (stored on AgentJobInfo for per-job "Copy All"). */
|
|
81
100
|
diffContext?: AgentJobInfo["diffContext"];
|
|
101
|
+
/** Resolved review profile id at launch time. Stored on AgentJobInfo. */
|
|
102
|
+
reviewProfileId?: string;
|
|
103
|
+
/** Resolved review profile label at launch time. Stored on AgentJobInfo. */
|
|
104
|
+
reviewProfileLabel?: string;
|
|
82
105
|
} | null>;
|
|
83
106
|
/** Called when a job completes successfully — parse results and push annotations. */
|
|
84
107
|
onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string }) => void | Promise<void>;
|
|
85
108
|
}
|
|
86
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Best-effort model catalog for a marker engine, spawned once. The spawn lives
|
|
112
|
+
* HERE (per-runtime — child_process execFile) rather than in marker-review.ts,
|
|
113
|
+
* which must stay Bun-free for the Pi vendor build. ASYNC so it never blocks the
|
|
114
|
+
* event loop on the /capabilities request path (a slow/hanging CLI would otherwise
|
|
115
|
+
* freeze every other in-flight request for up to the timeout). Empty when discovery
|
|
116
|
+
* fails or the CLI is unauthenticated / has no providers configured — the UI falls
|
|
117
|
+
* back to the engine's default picker. Account/config-specific, so never hardcoded.
|
|
118
|
+
*/
|
|
119
|
+
async function discoverMarkerModels(engine: MarkerEngine): Promise<MarkerModel[]> {
|
|
120
|
+
try {
|
|
121
|
+
const { stdout } = await execFileAsync(engine.binary, engine.modelsArgv, {
|
|
122
|
+
timeout: 5000,
|
|
123
|
+
encoding: "utf8",
|
|
124
|
+
});
|
|
125
|
+
return engine.parseModels(stdout);
|
|
126
|
+
} catch {
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
87
131
|
export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
88
132
|
const { mode, getServerUrl, getCwd } = options;
|
|
89
133
|
|
|
@@ -99,11 +143,32 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
99
143
|
{ id: "codex", name: "Codex CLI", available: whichCmd("codex") },
|
|
100
144
|
{ id: "tour", name: "Code Tour", available: whichCmd("claude") || whichCmd("codex") },
|
|
101
145
|
];
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
146
|
+
// Marker engines (Cursor, OpenCode) — same shape, one loop. Available only in
|
|
147
|
+
// review mode when the binary is on PATH (NOTE: cursor's binary is `agent`).
|
|
148
|
+
// Model catalogs are discovered LAZILY (see buildCapabilitiesResponse) so a
|
|
149
|
+
// slow/unauthenticated `<binary> models` spawn never blocks startup.
|
|
150
|
+
for (const engine of Object.values(MARKER_ENGINES)) {
|
|
151
|
+
capabilities.push({
|
|
152
|
+
id: engine.id,
|
|
153
|
+
name: engine.name,
|
|
154
|
+
available: mode === "review" && whichCmd(engine.binary),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const markerModelsCache = new Map<string, MarkerModel[]>();
|
|
159
|
+
async function buildCapabilitiesResponse(): Promise<AgentCapabilities> {
|
|
160
|
+
const providers = await Promise.all(capabilities.map(async (c) => {
|
|
161
|
+
const engine = MARKER_ENGINES[c.id as "cursor" | "opencode"];
|
|
162
|
+
if (!engine || !c.available) return c;
|
|
163
|
+
let models = markerModelsCache.get(engine.id);
|
|
164
|
+
if (!models) {
|
|
165
|
+
models = await discoverMarkerModels(engine);
|
|
166
|
+
markerModelsCache.set(engine.id, models);
|
|
167
|
+
}
|
|
168
|
+
return { ...c, models };
|
|
169
|
+
}));
|
|
170
|
+
return { mode, providers, available: providers.some((p) => p.available) };
|
|
171
|
+
}
|
|
107
172
|
|
|
108
173
|
// --- SSE broadcasting ---
|
|
109
174
|
function broadcast(event: AgentJobEvent): void {
|
|
@@ -118,15 +183,16 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
118
183
|
}
|
|
119
184
|
}
|
|
120
185
|
|
|
186
|
+
|
|
121
187
|
// --- Process lifecycle ---
|
|
122
188
|
function spawnJob(
|
|
189
|
+
id: string,
|
|
123
190
|
provider: string,
|
|
124
191
|
command: string[],
|
|
125
192
|
label: string,
|
|
126
193
|
outputPath?: string,
|
|
127
|
-
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean; prUrl?: string; diffScope?: string; diffContext?: AgentJobInfo["diffContext"] },
|
|
194
|
+
spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string; engine?: string; model?: string; effort?: string; reasoningEffort?: string; fastMode?: boolean; prUrl?: string; diffScope?: string; diffContext?: AgentJobInfo["diffContext"]; reviewProfileId?: string; reviewProfileLabel?: string },
|
|
128
195
|
): AgentJobInfo {
|
|
129
|
-
const id = crypto.randomUUID();
|
|
130
196
|
const source = jobSource(id);
|
|
131
197
|
|
|
132
198
|
const info: AgentJobInfo = {
|
|
@@ -146,6 +212,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
146
212
|
...(spawnOptions?.prUrl && { prUrl: spawnOptions.prUrl }),
|
|
147
213
|
...(spawnOptions?.diffScope && { diffScope: spawnOptions.diffScope }),
|
|
148
214
|
...(spawnOptions?.diffContext && { diffContext: spawnOptions.diffContext }),
|
|
215
|
+
...(spawnOptions?.reviewProfileId && { reviewProfileId: spawnOptions.reviewProfileId }),
|
|
216
|
+
...(spawnOptions?.reviewProfileLabel && { reviewProfileLabel: spawnOptions.reviewProfileLabel }),
|
|
149
217
|
};
|
|
150
218
|
|
|
151
219
|
let proc: ChildProcess | null = null;
|
|
@@ -183,31 +251,47 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
183
251
|
if (spawnOptions?.cwd) jobOutputPaths.set(`${id}:cwd`, spawnOptions.cwd);
|
|
184
252
|
broadcast({ type: "job:started", job: { ...info } });
|
|
185
253
|
|
|
186
|
-
// --- Stdout capture (Claude
|
|
254
|
+
// --- Stdout capture (Claude/Cursor stream-json) ---
|
|
187
255
|
let stdoutBuf = "";
|
|
188
256
|
if (captureStdout && proc.stdout) {
|
|
257
|
+
// Format one complete JSONL line into a live-log delta (skip result
|
|
258
|
+
// events — handled in onJobComplete).
|
|
259
|
+
const emitLogLine = (line: string) => {
|
|
260
|
+
if (!line.trim()) return;
|
|
261
|
+
// Tour jobs with the Claude engine also stream Claude JSONL.
|
|
262
|
+
if (provider === "claude" || spawnOptions?.engine === "claude") {
|
|
263
|
+
const formatted = formatClaudeLogEvent(line);
|
|
264
|
+
if (formatted !== null) broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
// Marker engines (Cursor, OpenCode): map their NDJSON stream events
|
|
268
|
+
// into readable log deltas via the engine's own formatter (Cursor
|
|
269
|
+
// applies the partial-output dedup rule; OpenCode reads text parts).
|
|
270
|
+
const markerEngine = MARKER_ENGINES[provider as "cursor" | "opencode"];
|
|
271
|
+
if (markerEngine) {
|
|
272
|
+
const formatted = formatMarkerLogEvent(line, markerEngine);
|
|
273
|
+
if (formatted !== null) broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const event = JSON.parse(line);
|
|
278
|
+
if (event.type === 'result') return;
|
|
279
|
+
} catch { /* not JSON — forward as raw log */ }
|
|
280
|
+
broadcast({ type: "job:log", jobId: id, delta: line + '\n' });
|
|
281
|
+
};
|
|
282
|
+
// stream-json output is NDJSON and chunk boundaries are arbitrary —
|
|
283
|
+
// carry the trailing partial line until a later chunk completes it,
|
|
284
|
+
// otherwise records split across chunks are dropped from live logs.
|
|
285
|
+
let logLineCarry = "";
|
|
189
286
|
proc.stdout.on("data", (chunk: Buffer) => {
|
|
190
287
|
const text = chunk.toString();
|
|
191
288
|
stdoutBuf += text;
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const lines
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
if (provider === "claude" || spawnOptions?.engine === "claude") {
|
|
199
|
-
const formatted = formatClaudeLogEvent(line);
|
|
200
|
-
if (formatted !== null) {
|
|
201
|
-
broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
|
|
202
|
-
}
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
try {
|
|
206
|
-
const event = JSON.parse(line);
|
|
207
|
-
if (event.type === 'result') continue;
|
|
208
|
-
} catch { /* not JSON — forward as raw log */ }
|
|
209
|
-
broadcast({ type: "job:log", jobId: id, delta: line + '\n' });
|
|
210
|
-
}
|
|
289
|
+
const lines = (logLineCarry + text).split('\n');
|
|
290
|
+
logLineCarry = lines.pop() ?? "";
|
|
291
|
+
for (const line of lines) emitLogLine(line);
|
|
292
|
+
});
|
|
293
|
+
proc.stdout.on("end", () => {
|
|
294
|
+
if (logLineCarry) emitLogLine(logLineCarry);
|
|
211
295
|
});
|
|
212
296
|
}
|
|
213
297
|
|
|
@@ -265,13 +349,19 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
265
349
|
stdout: captureStdout ? stdoutBuf : undefined,
|
|
266
350
|
cwd: jobCwd,
|
|
267
351
|
});
|
|
268
|
-
} catch {
|
|
269
|
-
//
|
|
352
|
+
} catch (err) {
|
|
353
|
+
// Claude/Codex are fail-open; Cursor and OpenCode are fail-closed — an
|
|
354
|
+
// unexpected throw during prompt-enforced ingestion must fail the job,
|
|
355
|
+
// not pass it. (Their handlers normally fail by mutation and never
|
|
356
|
+
// throw; this guards future refactors.)
|
|
357
|
+
if (MARKER_ENGINES[provider as "cursor" | "opencode"]) {
|
|
358
|
+
entry.info.status = "failed";
|
|
359
|
+
entry.info.error = err instanceof Error ? err.message : `${provider} result ingestion failed`;
|
|
360
|
+
}
|
|
270
361
|
}
|
|
271
362
|
}
|
|
272
363
|
jobOutputPaths.delete(id);
|
|
273
364
|
jobOutputPaths.delete(`${id}:cwd`);
|
|
274
|
-
|
|
275
365
|
broadcast({ type: "job:completed", job: { ...entry.info } });
|
|
276
366
|
});
|
|
277
367
|
|
|
@@ -344,7 +434,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
344
434
|
): Promise<boolean> {
|
|
345
435
|
// --- GET /api/agents/capabilities ---
|
|
346
436
|
if (url.pathname === CAPABILITIES && req.method === "GET") {
|
|
347
|
-
json(res,
|
|
437
|
+
json(res, await buildCapabilitiesResponse());
|
|
348
438
|
return true;
|
|
349
439
|
}
|
|
350
440
|
|
|
@@ -405,6 +495,22 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
405
495
|
if (url.pathname === JOBS && req.method === "POST") {
|
|
406
496
|
try {
|
|
407
497
|
const body = await parseBody(req);
|
|
498
|
+
|
|
499
|
+
// Reject unknown fields rather than silently ignoring them (per the
|
|
500
|
+
// custom-reviews spec — a typo'd field should fail loud, not no-op).
|
|
501
|
+
const KNOWN_JOB_FIELDS = new Set([
|
|
502
|
+
"provider", "command", "label",
|
|
503
|
+
"engine", "model", "reasoningEffort", "effort", "fastMode",
|
|
504
|
+
"reviewProfileId",
|
|
505
|
+
]);
|
|
506
|
+
if (body && typeof body === "object") {
|
|
507
|
+
const unknown = Object.keys(body).filter((k) => !KNOWN_JOB_FIELDS.has(k));
|
|
508
|
+
if (unknown.length > 0) {
|
|
509
|
+
json(res, { error: `Unknown field(s): ${unknown.join(", ")}` }, 400);
|
|
510
|
+
return true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
408
514
|
const provider = typeof body.provider === "string" ? body.provider : "";
|
|
409
515
|
let rawCommand = Array.isArray(body.command) ? body.command : [];
|
|
410
516
|
let command = rawCommand.filter((c: unknown): c is string => typeof c === "string");
|
|
@@ -418,6 +524,19 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
418
524
|
return true;
|
|
419
525
|
}
|
|
420
526
|
|
|
527
|
+
// Fail-closed enforcement for server-owned providers: the command MUST
|
|
528
|
+
// be built server-side. Client-supplied argv is never spawned for these
|
|
529
|
+
// providers — a null/throwing builder becomes an error, not a fallback.
|
|
530
|
+
if (SERVER_BUILT_PROVIDERS.has(provider)) {
|
|
531
|
+
if (!options.buildCommand) {
|
|
532
|
+
json(res, { error: `Provider ${provider} requires server-built command` }, 400);
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
// Discard any client-supplied argv so a null build cleanly hits the
|
|
536
|
+
// `command.length === 0` guard below instead of falling through.
|
|
537
|
+
command = [];
|
|
538
|
+
}
|
|
539
|
+
|
|
421
540
|
// Try server-side command building for known providers
|
|
422
541
|
let captureStdout = false;
|
|
423
542
|
let stdinPrompt: string | undefined;
|
|
@@ -431,6 +550,9 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
431
550
|
let jobPrUrl: string | undefined;
|
|
432
551
|
let jobDiffScope: string | undefined;
|
|
433
552
|
let jobDiffContext: AgentJobInfo["diffContext"] | undefined;
|
|
553
|
+
let jobReviewProfileId: string | undefined;
|
|
554
|
+
let jobReviewProfileLabel: string | undefined;
|
|
555
|
+
const jobId = crypto.randomUUID();
|
|
434
556
|
if (options.buildCommand) {
|
|
435
557
|
// Thread config from POST body to buildCommand
|
|
436
558
|
const config: Record<string, unknown> = {};
|
|
@@ -439,6 +561,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
439
561
|
if (typeof body.reasoningEffort === "string") config.reasoningEffort = body.reasoningEffort;
|
|
440
562
|
if (typeof body.effort === "string") config.effort = body.effort;
|
|
441
563
|
if (body.fastMode === true) config.fastMode = true;
|
|
564
|
+
if (typeof body.reviewProfileId === "string") config.reviewProfileId = body.reviewProfileId;
|
|
442
565
|
const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined);
|
|
443
566
|
if (built) {
|
|
444
567
|
command = built.command;
|
|
@@ -456,6 +579,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
456
579
|
jobPrUrl = built.prUrl;
|
|
457
580
|
jobDiffScope = built.diffScope;
|
|
458
581
|
jobDiffContext = built.diffContext;
|
|
582
|
+
jobReviewProfileId = built.reviewProfileId;
|
|
583
|
+
jobReviewProfileLabel = built.reviewProfileLabel;
|
|
459
584
|
}
|
|
460
585
|
}
|
|
461
586
|
|
|
@@ -464,7 +589,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
464
589
|
return true;
|
|
465
590
|
}
|
|
466
591
|
|
|
467
|
-
const job = spawnJob(provider, command, label, outputPath, {
|
|
592
|
+
const job = spawnJob(jobId, provider, command, label, outputPath, {
|
|
468
593
|
captureStdout,
|
|
469
594
|
stdinPrompt,
|
|
470
595
|
cwd: spawnCwd,
|
|
@@ -477,6 +602,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
|
|
|
477
602
|
prUrl: jobPrUrl,
|
|
478
603
|
diffScope: jobDiffScope,
|
|
479
604
|
diffContext: jobDiffContext,
|
|
605
|
+
reviewProfileId: jobReviewProfileId,
|
|
606
|
+
reviewProfileLabel: jobReviewProfileLabel,
|
|
480
607
|
});
|
|
481
608
|
json(res, { job }, 201);
|
|
482
609
|
} catch (err) {
|