@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.
@@ -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
- /** Server-side command builder for known providers (codex, claude, tour). */
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
- const capabilitiesResponse: AgentCapabilities = {
103
- mode,
104
- providers: capabilities,
105
- available: capabilities.some((c) => c.available),
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 JSONL streaming) ---
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
- // Forward JSONL lines as log events
194
- const lines = text.split('\n');
195
- for (const line of lines) {
196
- if (!line.trim()) continue;
197
- // Tour jobs with the Claude engine also stream Claude JSONL.
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
- // Result ingestion failure shouldn't prevent job completion broadcast
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, capabilitiesResponse);
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) {