@plannotator/pi-extension 0.16.7 → 0.17.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/generated/agent-jobs.ts +11 -0
- package/generated/claude-review.ts +345 -0
- package/generated/codex-review.ts +382 -0
- package/generated/external-annotation.ts +6 -0
- package/generated/path-utils.ts +19 -0
- package/generated/pr-github.ts +100 -2
- package/generated/pr-gitlab.ts +1 -0
- package/generated/pr-provider.ts +26 -0
- package/generated/resolve-file.ts +56 -3
- package/generated/review-core.ts +32 -0
- package/generated/worktree.ts +120 -0
- package/package.json +1 -1
- package/plannotator.html +78 -77
- package/review-editor.html +586 -269
- package/server/agent-jobs.ts +139 -12
- package/server/external-annotations.ts +8 -0
- package/server/network.test.ts +96 -0
- package/server/network.ts +21 -4
- package/server/serverReview.ts +90 -6
package/server/agent-jobs.ts
CHANGED
|
@@ -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:
|
|
115
|
-
stdio: [
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
136
|
-
|
|
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 : "
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
|
14
|
+
* Honors PLANNOTATOR_REMOTE as a tri-state override, or detects SSH_TTY/SSH_CONNECTION.
|
|
15
15
|
*/
|
|
16
|
-
function
|
|
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
|
|
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): {
|
package/server/serverReview.ts
CHANGED
|
@@ -56,6 +56,20 @@ import {
|
|
|
56
56
|
submitPRReview,
|
|
57
57
|
} from "./pr.js";
|
|
58
58
|
import { getRepoInfo } from "./project.js";
|
|
59
|
+
import {
|
|
60
|
+
CODEX_REVIEW_SYSTEM_PROMPT,
|
|
61
|
+
buildCodexReviewUserMessage,
|
|
62
|
+
buildCodexCommand,
|
|
63
|
+
generateOutputPath,
|
|
64
|
+
parseCodexOutput,
|
|
65
|
+
transformReviewFindings,
|
|
66
|
+
} from "../generated/codex-review.js";
|
|
67
|
+
import {
|
|
68
|
+
CLAUDE_REVIEW_PROMPT,
|
|
69
|
+
buildClaudeCommand,
|
|
70
|
+
parseClaudeStreamOutput,
|
|
71
|
+
transformClaudeFindings,
|
|
72
|
+
} from "../generated/claude-review.js";
|
|
59
73
|
|
|
60
74
|
export interface ReviewServerResult {
|
|
61
75
|
port: number;
|
|
@@ -154,15 +168,84 @@ export async function startReviewServer(options: {
|
|
|
154
168
|
|
|
155
169
|
// Agent jobs — background process manager (late-binds serverUrl via getter)
|
|
156
170
|
let serverUrl = "";
|
|
171
|
+
// Worktree-aware cwd resolver — shared by getCwd, buildCommand, and onJobComplete
|
|
172
|
+
function resolveAgentCwd(): string {
|
|
173
|
+
if (currentDiffType.startsWith("worktree:")) {
|
|
174
|
+
const parsed = parseWorktreeDiffType(currentDiffType);
|
|
175
|
+
if (parsed) return parsed.path;
|
|
176
|
+
}
|
|
177
|
+
return options.gitContext?.cwd ?? process.cwd();
|
|
178
|
+
}
|
|
157
179
|
const agentJobs = createAgentJobHandler({
|
|
158
180
|
mode: "review",
|
|
159
181
|
getServerUrl: () => serverUrl,
|
|
160
|
-
getCwd:
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
182
|
+
getCwd: resolveAgentCwd,
|
|
183
|
+
|
|
184
|
+
async buildCommand(provider) {
|
|
185
|
+
const cwd = resolveAgentCwd();
|
|
186
|
+
const hasLocalAccess = !!options.gitContext;
|
|
187
|
+
const userMessage = buildCodexReviewUserMessage(
|
|
188
|
+
currentPatch,
|
|
189
|
+
currentDiffType,
|
|
190
|
+
{ defaultBranch: options.gitContext?.defaultBranch, hasLocalAccess },
|
|
191
|
+
options.prMetadata,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
if (provider === "codex") {
|
|
195
|
+
const outputPath = generateOutputPath();
|
|
196
|
+
const prompt = CODEX_REVIEW_SYSTEM_PROMPT + "\n\n---\n\n" + userMessage;
|
|
197
|
+
const command = await buildCodexCommand({ cwd, outputPath, prompt });
|
|
198
|
+
return { command, outputPath, prompt, label: "Codex Review" };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (provider === "claude") {
|
|
202
|
+
const prompt = CLAUDE_REVIEW_PROMPT + "\n\n---\n\n" + userMessage;
|
|
203
|
+
const { command, stdinPrompt } = buildClaudeCommand(prompt);
|
|
204
|
+
return { command, stdinPrompt, prompt, cwd, label: "Claude Code Review", captureStdout: true };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return null;
|
|
208
|
+
},
|
|
209
|
+
|
|
210
|
+
async onJobComplete(job, meta) {
|
|
211
|
+
const cwd = resolveAgentCwd();
|
|
212
|
+
|
|
213
|
+
if (job.provider === "codex" && meta.outputPath) {
|
|
214
|
+
const output = await parseCodexOutput(meta.outputPath);
|
|
215
|
+
if (!output) return;
|
|
216
|
+
|
|
217
|
+
job.summary = {
|
|
218
|
+
correctness: output.overall_correctness,
|
|
219
|
+
explanation: output.overall_explanation,
|
|
220
|
+
confidence: output.overall_confidence_score,
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
if (output.findings.length > 0) {
|
|
224
|
+
const annotations = transformReviewFindings(output.findings, job.source, cwd, "Codex");
|
|
225
|
+
const result = externalAnnotations.addAnnotations({ annotations });
|
|
226
|
+
if ("error" in result) console.error(`[codex-review] addAnnotations error:`, result.error);
|
|
227
|
+
}
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (job.provider === "claude" && meta.stdout) {
|
|
232
|
+
const output = parseClaudeStreamOutput(meta.stdout);
|
|
233
|
+
if (!output) return;
|
|
234
|
+
|
|
235
|
+
const total = output.summary.important + output.summary.nit + output.summary.pre_existing;
|
|
236
|
+
job.summary = {
|
|
237
|
+
correctness: output.summary.important === 0 ? "Correct" : "Issues Found",
|
|
238
|
+
explanation: `${output.summary.important} important, ${output.summary.nit} nit, ${output.summary.pre_existing} pre-existing`,
|
|
239
|
+
confidence: total === 0 ? 1.0 : Math.max(0, 1.0 - (output.summary.important * 0.2)),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
if (output.findings.length > 0) {
|
|
243
|
+
const annotations = transformClaudeFindings(output.findings, job.source, cwd);
|
|
244
|
+
const result = externalAnnotations.addAnnotations({ annotations });
|
|
245
|
+
if ("error" in result) console.error(`[claude-review] addAnnotations error:`, result.error);
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
164
248
|
}
|
|
165
|
-
return options.gitContext?.cwd ?? process.cwd();
|
|
166
249
|
},
|
|
167
250
|
});
|
|
168
251
|
const sharingEnabled =
|
|
@@ -448,8 +531,9 @@ export async function startReviewServer(options: {
|
|
|
448
531
|
|
|
449
532
|
if (isPRMode && prRef && prMeta) {
|
|
450
533
|
try {
|
|
534
|
+
const oldSha = prMeta.mergeBaseSha ?? prMeta.baseSha;
|
|
451
535
|
const [oldContent, newContent] = await Promise.all([
|
|
452
|
-
fetchPRFileContent(prRef,
|
|
536
|
+
fetchPRFileContent(prRef, oldSha, oldPath || filePath),
|
|
453
537
|
fetchPRFileContent(prRef, prMeta.headSha, filePath),
|
|
454
538
|
]);
|
|
455
539
|
json(res, { oldContent, newContent });
|