@plannotator/pi-extension 0.16.7 → 0.17.1

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.
@@ -20,6 +20,7 @@ import {
20
20
  AGENT_HEARTBEAT_COMMENT,
21
21
  AGENT_HEARTBEAT_INTERVAL_MS,
22
22
  } from "../generated/agent-jobs.js";
23
+ import { formatClaudeLogEvent } from "../generated/claude-review.js";
23
24
  import { json, parseBody } from "./helpers.js";
24
25
 
25
26
  // ---------------------------------------------------------------------------
@@ -53,6 +54,18 @@ export interface AgentJobHandlerOptions {
53
54
  mode: "plan" | "review" | "annotate";
54
55
  getServerUrl: () => string;
55
56
  getCwd: () => string;
57
+ /** Server-side command builder for known providers (codex, claude). */
58
+ buildCommand?: (provider: string) => Promise<{
59
+ command: string[];
60
+ outputPath?: string;
61
+ captureStdout?: boolean;
62
+ stdinPrompt?: string;
63
+ cwd?: string;
64
+ prompt?: string;
65
+ label?: string;
66
+ } | null>;
67
+ /** Called when a job completes successfully — parse results and push annotations. */
68
+ onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string }) => void | Promise<void>;
56
69
  }
57
70
 
58
71
  export function createAgentJobHandler(options: AgentJobHandlerOptions) {
@@ -60,6 +73,7 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
60
73
 
61
74
  // --- State ---
62
75
  const jobs = new Map<string, { info: AgentJobInfo; proc: ChildProcess | null }>();
76
+ const jobOutputPaths = new Map<string, string>();
63
77
  const subscribers = new Set<ServerResponse>();
64
78
  let version = 0;
65
79
 
@@ -67,7 +81,6 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
67
81
  const capabilities: AgentCapability[] = [
68
82
  { id: "claude", name: "Claude Code", available: whichCmd("claude") },
69
83
  { id: "codex", name: "Codex CLI", available: whichCmd("codex") },
70
- { id: "shell", name: "Shell Command", available: true },
71
84
  ];
72
85
  const capabilitiesResponse: AgentCapabilities = {
73
86
  mode,
@@ -93,6 +106,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
93
106
  provider: string,
94
107
  command: string[],
95
108
  label: string,
109
+ outputPath?: string,
110
+ spawnOptions?: { captureStdout?: boolean; stdinPrompt?: string; cwd?: string; prompt?: string },
96
111
  ): AgentJobInfo {
97
112
  const id = crypto.randomUUID();
98
113
  const source = jobSource(id);
@@ -105,14 +120,23 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
105
120
  status: "starting",
106
121
  startedAt: Date.now(),
107
122
  command,
123
+ cwd: getCwd(),
108
124
  };
109
125
 
110
126
  let proc: ChildProcess | null = null;
111
127
 
112
128
  try {
129
+ const spawnCwd = spawnOptions?.cwd ?? getCwd();
130
+ const captureStdout = spawnOptions?.captureStdout ?? false;
131
+ const hasStdinPrompt = !!spawnOptions?.stdinPrompt;
132
+
113
133
  proc = spawn(command[0], command.slice(1), {
114
- cwd: getCwd(),
115
- stdio: ["ignore", "ignore", "pipe"],
134
+ cwd: spawnCwd,
135
+ stdio: [
136
+ hasStdinPrompt ? "pipe" : "ignore",
137
+ captureStdout ? "pipe" : "ignore",
138
+ "pipe",
139
+ ],
116
140
  env: {
117
141
  ...process.env,
118
142
  PLANNOTATOR_AGENT_SOURCE: source,
@@ -120,20 +144,80 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
120
144
  },
121
145
  });
122
146
 
147
+ // Write prompt to stdin and close (for providers that read prompt from stdin)
148
+ if (hasStdinPrompt && proc.stdin) {
149
+ proc.stdin.write(spawnOptions!.stdinPrompt!);
150
+ proc.stdin.end();
151
+ }
152
+
123
153
  info.status = "running";
154
+ info.cwd = spawnCwd;
155
+ if (spawnOptions?.prompt) info.prompt = spawnOptions.prompt;
124
156
  jobs.set(id, { info, proc });
157
+ if (outputPath) jobOutputPaths.set(id, outputPath);
158
+ if (spawnOptions?.cwd) jobOutputPaths.set(`${id}:cwd`, spawnOptions.cwd);
125
159
  broadcast({ type: "job:started", job: { ...info } });
126
160
 
127
- // Accumulate stderr continuously (must attach before exit fires)
161
+ // --- Stdout capture (Claude JSONL streaming) ---
162
+ let stdoutBuf = "";
163
+ if (captureStdout && proc.stdout) {
164
+ proc.stdout.on("data", (chunk: Buffer) => {
165
+ const text = chunk.toString();
166
+ stdoutBuf += text;
167
+
168
+ // Forward JSONL lines as log events
169
+ const lines = text.split('\n');
170
+ for (const line of lines) {
171
+ if (!line.trim()) continue;
172
+ if (provider === "claude") {
173
+ const formatted = formatClaudeLogEvent(line);
174
+ if (formatted !== null) {
175
+ broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
176
+ }
177
+ continue;
178
+ }
179
+ try {
180
+ const event = JSON.parse(line);
181
+ if (event.type === 'result') continue;
182
+ } catch { /* not JSON — forward as raw log */ }
183
+ broadcast({ type: "job:log", jobId: id, delta: line + '\n' });
184
+ }
185
+ });
186
+ }
187
+
188
+ // --- Stderr: buffer tail for errors + live log streaming ---
128
189
  let stderrBuf = "";
190
+ let logPending = "";
191
+ let logFlushTimer: ReturnType<typeof setTimeout> | null = null;
192
+
129
193
  if (proc.stderr) {
130
194
  proc.stderr.on("data", (chunk: Buffer) => {
131
- stderrBuf = (stderrBuf + chunk.toString()).slice(-500);
195
+ const text = chunk.toString();
196
+ stderrBuf = (stderrBuf + text).slice(-500);
197
+ logPending += text;
198
+
199
+ if (!logFlushTimer) {
200
+ logFlushTimer = setTimeout(() => {
201
+ if (logPending) {
202
+ broadcast({ type: "job:log", jobId: id, delta: logPending });
203
+ logPending = "";
204
+ }
205
+ logFlushTimer = null;
206
+ }, 200);
207
+ }
132
208
  });
133
209
  }
134
210
 
135
- // Monitor process exit
136
- proc.on("exit", (exitCode) => {
211
+ // Monitor process close (fires after stdio streams are fully drained,
212
+ // unlike 'exit' which fires before — critical for stdout capture)
213
+ proc.on("close", async (exitCode) => {
214
+ // Flush remaining stderr
215
+ if (logFlushTimer) { clearTimeout(logFlushTimer); logFlushTimer = null; }
216
+ if (logPending) {
217
+ broadcast({ type: "job:log", jobId: id, delta: logPending });
218
+ logPending = "";
219
+ }
220
+
137
221
  const entry = jobs.get(id);
138
222
  if (!entry || isTerminalStatus(entry.info.status)) return;
139
223
 
@@ -145,6 +229,23 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
145
229
  entry.info.error = stderrBuf;
146
230
  }
147
231
 
232
+ // Ingest results before broadcasting completion
233
+ const jobOutputPath = jobOutputPaths.get(id);
234
+ const jobCwd = jobOutputPaths.get(`${id}:cwd`);
235
+ if (exitCode === 0 && options.onJobComplete) {
236
+ try {
237
+ await options.onJobComplete(entry.info, {
238
+ outputPath: jobOutputPath,
239
+ stdout: captureStdout ? stdoutBuf : undefined,
240
+ cwd: jobCwd,
241
+ });
242
+ } catch {
243
+ // Result ingestion failure shouldn't prevent job completion broadcast
244
+ }
245
+ }
246
+ jobOutputPaths.delete(id);
247
+ jobOutputPaths.delete(`${id}:cwd`);
248
+
148
249
  broadcast({ type: "job:completed", job: { ...entry.info } });
149
250
  });
150
251
 
@@ -185,6 +286,8 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
185
286
 
186
287
  entry.info.status = "killed";
187
288
  entry.info.endedAt = Date.now();
289
+ jobOutputPaths.delete(id);
290
+ jobOutputPaths.delete(`${id}:cwd`);
188
291
  broadcast({ type: "job:completed", job: { ...entry.info } });
189
292
  return true;
190
293
  }
@@ -276,10 +379,11 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
276
379
  if (url.pathname === JOBS && req.method === "POST") {
277
380
  try {
278
381
  const body = await parseBody(req);
279
- const provider = typeof body.provider === "string" ? body.provider : "shell";
280
- const rawCommand = Array.isArray(body.command) ? body.command : [];
281
- const command = rawCommand.filter((c: unknown): c is string => typeof c === "string");
282
- const label = typeof body.label === "string" ? body.label : `${provider} agent`;
382
+ const provider = typeof body.provider === "string" ? body.provider : "";
383
+ let rawCommand = Array.isArray(body.command) ? body.command : [];
384
+ let command = rawCommand.filter((c: unknown): c is string => typeof c === "string");
385
+ let label = typeof body.label === "string" ? body.label : `${provider} agent`;
386
+ let outputPath: string | undefined;
283
387
 
284
388
  // Validate provider is a known, available capability
285
389
  const cap = capabilities.find((c) => c.id === provider);
@@ -288,12 +392,35 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
288
392
  return true;
289
393
  }
290
394
 
395
+ // Try server-side command building for known providers
396
+ let captureStdout = false;
397
+ let stdinPrompt: string | undefined;
398
+ let spawnCwd: string | undefined;
399
+ let promptText: string | undefined;
400
+ if (options.buildCommand) {
401
+ const built = await options.buildCommand(provider);
402
+ if (built) {
403
+ command = built.command;
404
+ outputPath = built.outputPath;
405
+ captureStdout = built.captureStdout ?? false;
406
+ stdinPrompt = built.stdinPrompt;
407
+ spawnCwd = built.cwd;
408
+ promptText = built.prompt;
409
+ if (built.label) label = built.label;
410
+ }
411
+ }
412
+
291
413
  if (command.length === 0) {
292
414
  json(res, { error: 'Missing "command" array' }, 400);
293
415
  return true;
294
416
  }
295
417
 
296
- const job = spawnJob(provider, command, label);
418
+ const job = spawnJob(provider, command, label, outputPath, {
419
+ captureStdout,
420
+ stdinPrompt,
421
+ cwd: spawnCwd,
422
+ prompt: promptText,
423
+ });
297
424
  json(res, { job }, 201);
298
425
  } catch {
299
426
  json(res, { error: "Invalid JSON" }, 400);
@@ -49,6 +49,14 @@ export function createExternalAnnotationHandler(mode: "plan" | "review") {
49
49
  });
50
50
 
51
51
  return {
52
+ /** Push annotations directly into the store (bypasses HTTP, reuses same validation). */
53
+ addAnnotations(body: unknown): { ids: string[] } | { error: string } {
54
+ const parsed = transform(body);
55
+ if ("error" in parsed) return { error: parsed.error };
56
+ const created = store.add(parsed.annotations);
57
+ return { ids: created.map((a: { id: string }) => a.id) };
58
+ },
59
+
52
60
  async handle(
53
61
  req: IncomingMessage,
54
62
  res: ServerResponse,
@@ -0,0 +1,96 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { getServerPort, isRemoteSession } from "./network";
3
+
4
+ const savedEnv: Record<string, string | undefined> = {};
5
+ const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION"];
6
+
7
+ function clearEnv() {
8
+ for (const key of envKeys) {
9
+ savedEnv[key] = process.env[key];
10
+ delete process.env[key];
11
+ }
12
+ }
13
+
14
+ afterEach(() => {
15
+ for (const key of envKeys) {
16
+ if (savedEnv[key] !== undefined) {
17
+ process.env[key] = savedEnv[key];
18
+ } else {
19
+ delete process.env[key];
20
+ }
21
+ }
22
+ });
23
+
24
+ describe("pi remote detection", () => {
25
+ test("false by default", () => {
26
+ clearEnv();
27
+ expect(isRemoteSession()).toBe(false);
28
+ });
29
+
30
+ test("true when PLANNOTATOR_REMOTE=1", () => {
31
+ clearEnv();
32
+ process.env.PLANNOTATOR_REMOTE = "1";
33
+ expect(isRemoteSession()).toBe(true);
34
+ });
35
+
36
+ test("true when PLANNOTATOR_REMOTE=true", () => {
37
+ clearEnv();
38
+ process.env.PLANNOTATOR_REMOTE = "true";
39
+ expect(isRemoteSession()).toBe(true);
40
+ });
41
+
42
+ test("false when PLANNOTATOR_REMOTE=0", () => {
43
+ clearEnv();
44
+ process.env.PLANNOTATOR_REMOTE = "0";
45
+ expect(isRemoteSession()).toBe(false);
46
+ });
47
+
48
+ test("false when PLANNOTATOR_REMOTE=false", () => {
49
+ clearEnv();
50
+ process.env.PLANNOTATOR_REMOTE = "false";
51
+ expect(isRemoteSession()).toBe(false);
52
+ });
53
+
54
+ test("PLANNOTATOR_REMOTE=false overrides SSH_TTY", () => {
55
+ clearEnv();
56
+ process.env.PLANNOTATOR_REMOTE = "false";
57
+ process.env.SSH_TTY = "/dev/pts/0";
58
+ expect(isRemoteSession()).toBe(false);
59
+ });
60
+
61
+ test("PLANNOTATOR_REMOTE=0 overrides SSH_CONNECTION", () => {
62
+ clearEnv();
63
+ process.env.PLANNOTATOR_REMOTE = "0";
64
+ process.env.SSH_CONNECTION = "192.168.1.1 12345 192.168.1.2 22";
65
+ expect(isRemoteSession()).toBe(false);
66
+ });
67
+
68
+ test("true when SSH_TTY is set and env var is unset", () => {
69
+ clearEnv();
70
+ process.env.SSH_TTY = "/dev/pts/0";
71
+ expect(isRemoteSession()).toBe(true);
72
+ });
73
+ });
74
+
75
+ describe("pi port selection", () => {
76
+ test("uses random local port when false overrides SSH", () => {
77
+ clearEnv();
78
+ process.env.PLANNOTATOR_REMOTE = "false";
79
+ process.env.SSH_TTY = "/dev/pts/0";
80
+ expect(getServerPort()).toEqual({ port: 0, portSource: "random" });
81
+ });
82
+
83
+ test("uses default remote port when SSH is detected", () => {
84
+ clearEnv();
85
+ process.env.SSH_CONNECTION = "192.168.1.1 12345 192.168.1.2 22";
86
+ expect(getServerPort()).toEqual({ port: 19432, portSource: "remote-default" });
87
+ });
88
+
89
+ test("PLANNOTATOR_PORT still takes precedence", () => {
90
+ clearEnv();
91
+ process.env.PLANNOTATOR_REMOTE = "false";
92
+ process.env.SSH_TTY = "/dev/pts/0";
93
+ process.env.PLANNOTATOR_PORT = "9999";
94
+ expect(getServerPort()).toEqual({ port: 9999, portSource: "env" });
95
+ });
96
+ });
package/server/network.ts CHANGED
@@ -11,13 +11,30 @@ const DEFAULT_REMOTE_PORT = 19432;
11
11
 
12
12
  /**
13
13
  * Check if running in a remote session (SSH, devcontainer, etc.)
14
- * Honors PLANNOTATOR_REMOTE env var, or detects SSH_TTY/SSH_CONNECTION.
14
+ * Honors PLANNOTATOR_REMOTE as a tri-state override, or detects SSH_TTY/SSH_CONNECTION.
15
15
  */
16
- function isRemoteSession(): boolean {
16
+ function getRemoteOverride(): boolean | null {
17
17
  const remote = process.env.PLANNOTATOR_REMOTE;
18
+ if (remote === undefined) {
19
+ return null;
20
+ }
21
+
18
22
  if (remote === "1" || remote?.toLowerCase() === "true") {
19
23
  return true;
20
24
  }
25
+
26
+ if (remote === "0" || remote?.toLowerCase() === "false") {
27
+ return false;
28
+ }
29
+
30
+ return null;
31
+ }
32
+
33
+ export function isRemoteSession(): boolean {
34
+ const remoteOverride = getRemoteOverride();
35
+ if (remoteOverride !== null) {
36
+ return remoteOverride;
37
+ }
21
38
  // Legacy SSH detection
22
39
  if (process.env.SSH_TTY || process.env.SSH_CONNECTION) {
23
40
  return true;
@@ -32,7 +49,7 @@ function isRemoteSession(): boolean {
32
49
  * - Local sessions use random port
33
50
  * Returns { port, portSource } so caller can notify user if needed.
34
51
  */
35
- function getServerPort(): {
52
+ export function getServerPort(): {
36
53
  port: number;
37
54
  portSource: "env" | "remote-default" | "random";
38
55
  } {
@@ -98,7 +115,7 @@ export async function listenOnPort(
98
115
 
99
116
  /**
100
117
  * Open URL in system browser (Node-compatible, no Bun $ dependency).
101
- * Honors PLANNOTATOR_BROWSER and BROWSER env vars, matching packages/server/browser.ts.
118
+ * Honors PLANNOTATOR_BROWSER and BROWSER env vars.
102
119
  * Returns { opened: true } if browser was opened, { opened: false, isRemote: true, url } if remote session.
103
120
  */
104
121
  export function openBrowser(url: string): {