Files
backnotprop__plannotator/apps/pi-extension/server/agent-jobs.ts
T
Michael Ramos bb404f8d14 feat: stacked PR review — PR switching, scope toggling, multi-PR posting (#620)
* feat(shared): add isSameProject, PR stack types, and PR list provider

Extends PRRef/PRMetadata with defaultBranch, PRStackInfo, PRStackTree,
PRStackNode, PRDiffScope, and PRListItem types. Adds isSameProject()
for owner/repo validation on PR switching. Adds fetchPRStack() and
fetchPRList() dispatch functions (GitHub-only for now, GitLab stubs).

Includes 9 new tests for isSameProject covering GitHub, GitLab, and
cross-platform scenarios.

For provenance purposes, this commit was AI assisted.

* feat(shared): add GitHub PR stack tree walking and PR list fetching

Implements fetchGhPRStack() which walks up/down the PR stack via
GraphQL, resolving numbers and titles for each node in the chain.
Collapses queryPRsByHead/queryPRsByBase into a single queryPRsByRef
helper. Adds fetchGhPRList() using gh pr list. Fixes GHE support
by removing hostnameArgs from fetchGhPRList (--repo already handles
GHE). Filters jq "null" string from defaultBranch detection.

For provenance purposes, this commit was AI assisted.

* feat(shared): fetch defaultBranch for GitLab MRs

Queries the project's default_branch via glab API so getPRStackInfo
can detect stacked MRs on GitLab. Best-effort — caught errors fall
back to undefined.

For provenance purposes, this commit was AI assisted.

* feat(shared): add PR stack detection and full-stack diff module

New pr-stack module with:
- getPRStackInfo(): detects stacked PRs from baseBranch vs defaultBranch
- getPRDiffScopeOptions(): generates layer/full-stack scope options
- runPRFullStackDiff(): computes diff from default branch to HEAD
- resolvePRFullStackBaseRef(): resolves origin/main or local main
- checkoutPRHead(): fetches and checks out a PR head in a worktree
- buildMinimalStackTree(): builds UI tree from stack info

Includes 13 tests covering ref resolution, branch fallbacks, and
GitLab ref formats.

For provenance purposes, this commit was AI assisted.

* feat(shared): add worktree pool for per-PR agent isolation

Creates a session-scoped pool of git worktrees — each PR visited
during a stacked review gets its own isolated checkout. Agents run
in their PR's worktree undisturbed by PR switches. Handles
deduplication of concurrent ensure() calls for the same PR.

Includes 11 tests covering caching, cross-repo restrictions, GitLab
ref formats, and cleanup.

For provenance purposes, this commit was AI assisted.

* feat(shared): add diffScope/prUrl to agent jobs, branch diff type

Adds prUrl and diffScope optional fields to AgentJobInfo so agent
findings carry the PR and scope context they were launched under.
Exports new pr-stack and worktree-pool modules from package.json.
Adds 'branch' to DefaultDiffType union for branch diff as default.

For provenance purposes, this commit was AI assisted.

* feat(ui): add PR annotation fields, Popover, and SearchableSelect

Extends CodeAnnotation with prUrl, prNumber, prTitle, prRepo, and
diffScope fields for stacked PR attribution. Adds shared Popover
wrapper around radix-ui. Adds SearchableSelect for filterable
dropdown lists (used by PR selector).

For provenance purposes, this commit was AI assisted.

* feat(ui): add branch diff as default option, new Git settings tab

Adds 'Branch' as a fourth default diff type option in both the
first-run dialog and settings panel. Moves the default diff type
setting from the Display tab to a new Git tab in review mode.
Updates config store validators to accept 'branch'.

For provenance purposes, this commit was AI assisted.

* feat(server): add prUrl/diffScope plumbing to agent jobs and prompts

Threads prUrl and diffScope through the agent job lifecycle so
findings carry the PR and scope they were generated under. Adds
full-stack prompt branch to codex-review and tour-review — when
in full-stack mode, the diff is inlined in the prompt instead of
telling the agent to run git diff. Re-exports isSameProject and
new PR provider functions from server/pr.ts.

For provenance purposes, this commit was AI assisted.

* feat(server): add stacked PR support to Bun review server

Adds PR switching, layer/full-stack scope toggling, PR list caching,
worktree pool integration, and multi-PR platform posting to the Bun
review server. Key additions:

- /api/pr-diff-scope: switch between layer and full-stack diffs
- /api/pr-list: cached PR list for the current repo
- /api/pr-switch: in-place navigation between PRs in a stack
- /api/pr-action: targetPrUrl support for multi-PR posting
- /api/file-content: full-stack branch for hunk expansion
- prSwitchCache/prStackTreeCache for session-scoped caching
- diffScope tagging on agent job completion
- Scope guard: returns 400 on full-stack diff failure instead of
  overwriting the working diff with empty content

For provenance purposes, this commit was AI assisted.

* feat(ai): pass cwd to Claude agent SDK for worktree support

Forwards the working directory to the Claude agent provider so
agents run in the correct worktree when reviewing stacked PRs.

For provenance purposes, this commit was AI assisted.

* feat(pi): add stacked PR support to Pi server (Bun parity)

Mirrors all stacked PR features from the Bun server:
- PR switching, scope toggling, PR list, multi-PR posting
- prSwitchCache/prStackTreeCache with initial PR seeding
- diffScope/prUrl plumbing in agent jobs
- Worktree pool creation and lifecycle
- Full-stack file-content resolution matching Bun's guard structure
- targetPrUrl support on /api/pr-action

Hoists worktreePool declaration to outer scope in plannotator-browser
to fix TS18004 scoping error. Updates vendor.sh for new shared modules.

For provenance purposes, this commit was AI assisted.

* feat(hook): create worktree pool for PR review sessions

Creates a worktree pool when opening a PR review with --local,
seeding it with the initial PR's checkout. Integrates pool cleanup
into server shutdown. Passes the pool to startReviewServer for
agent isolation during PR switching.

For provenance purposes, this commit was AI assisted.

* feat(review-editor): add hooks for PR stack, context, and annotations

- useAnnotationFactory: stamps prUrl/prNumber/prTitle/prRepo/diffScope
  onto annotations, only when viewing a stacked PR
- usePRStack: handles scope selection and PR switching with loading state
- usePRContext: adds URL-change detection to prevent stale-fetch race
  when switching PRs (discards in-flight responses for previous PR)

For provenance purposes, this commit was AI assisted.

* feat(review-editor): add stacked PR UI components

- PRSelector: searchable dropdown for switching between PRs in a repo
- PRSwitchOverlay: loading animation during PR switch
- StackedPRLabel: stack tree popover with scope selector and PR navigation
- ReviewSubmissionDialog: multi-PR submission dialog with per-target
  status, orphaned findings section with copy-as-markdown, and
  partial failure retry

For provenance purposes, this commit was AI assisted.

* feat(review-editor): multi-PR export with heading hierarchy

Updates exportReviewFeedback for multi-PR sessions:
- Groups annotations by prUrl, then by file within each PR
- Uses proper heading hierarchy (## for files, ### for annotations
  in multi-PR mode)
- Detects single-PR mismatch (annotations from a different PR than
  the current view) and uses annotation-level PR context
- Adds diffScope labels per PR group when present

Includes 5 new tests: multi-PR headings, single-PR mismatch,
diffScope labels, and non-stacked annotation handling.

For provenance purposes, this commit was AI assisted.

* feat(review-editor): integrate stacked PR into sidebar, diff panel, and agents

- ReviewSidebar: groups annotations by PR in multi-PR sessions,
  shows PR headers with annotation counts
- ReviewDiffPanel: filters annotations by prUrl and diffScope so
  only matching annotations appear in the diff gutter
- ReviewStateContext: adds prDiffScope to shared review state
- ReviewAgentJobDetailPanel: shows diffScope in job detail
- PRSummaryTab: shows stack info in PR summary
- index.css: PR switch shimmer and overlay animations

For provenance purposes, this commit was AI assisted.

* feat(review-editor): wire stacked PR into main review app

Integrates all stacked PR features into the review editor:
- PR stack state management (prStackInfo, prStackTree, prDiffScope)
- applyPRResponse: shared handler for PR switch and scope toggle,
  preserves active file index on scope changes
- Multi-PR platform posting via Promise.allSettled with parallel
  requests, partial failure retry, and per-target status tracking
- ReviewSubmissionDialog replaces inline dialog JSX
- useAnnotationFactory stamps PR context onto annotations
- keepalive on /api/feedback to survive tab closure
- Proper try/catch/finally on handlePlatformAction

For provenance purposes, this commit was AI assisted.

* docs: add stacked PR review documentation

Updates AGENTS.md, code-review command docs, and AI code review
guide with stacked PR review capabilities.

For provenance purposes, this commit was AI assisted.

* fix(server): stamp prNumber/prTitle/prRepo on agent findings

Agent annotations only had prUrl and diffScope, missing prNumber,
prTitle, and prRepo. When agent findings were the only annotations
for a PR target in the submission dialog, the target rendered as
#0 with no title. Now resolves full PR context from prSwitchCache
at job completion and stamps all five fields. Both Bun and Pi.

For provenance purposes, this commit was AI assisted.

* feat(ui): rename diff options — "Committed" replaces "Branch" / "Current PR Diff"

Consolidates two confusing committed-diff options into one:
- Settings/first-run: "Committed" — "Everything you've committed on this branch"
- Mid-session switcher: "Committed changes" (replaces both "vs main" and "Current PR Diff")

Uses merge-base under the hood (matches GitHub PR behavior). Removes
the two-dot branch diff from the UI — it stays in the runtime DiffType
union for backwards compat. Old "branch" values in config/cookies are
silently upgraded to merge-base.

Git settings tab now uses radio cards with descriptions instead of
a cramped segmented control. First-run dialog descriptions rewritten
in plain language — no git commands.

For provenance purposes, this commit was AI assisted.

* fix(review-editor): rename client-side "PR Diff" labels to "Committed changes"

DiffTypePicker.tsx had a hardcoded "PR Diff" override for merge-base
when the base picker is present. exportFeedback.ts also used "PR Diff"
in export labels. Both now say "Committed changes" to match the
server-side label and settings UI.

For provenance purposes, this commit was AI assisted.

* fix(server): discover stack UI for root PRs targeting the default branch

Root PRs (baseBranch === defaultBranch) were excluded from stack
detection because getPRStackInfo returned null. Now the server
always fetches the stack tree in PR mode. If the tree reveals
descendant PRs, prStackInfo is retroactively set with source
"tree-discovered", enabling the stack UI, scope selector, and
PR navigation from the root of a stack.

Both Bun and Pi servers updated. Adds "tree-discovered" to the
PRStackInfo source union.

For provenance purposes, this commit was AI assisted.

* fix(review-editor): derive diff scope from annotations, not UI state

The export function now reads diffScope from annotations instead of
the prReviewScope parameter. Fixes two issues:

1. Agent job "Copy All" showed the wrong scope when the user switched
   between layer/full-stack after launching the agent
2. Mixed-scope annotations produced a confusing "layer, full-stack"
   comma-joined label instead of grouping by scope

Extracts renderScopedGroups helper for scope-aware grouping — used
by both single-PR and multi-PR export paths. When annotations share
one scope, it appears in the header. When mixed, annotations are
grouped under ## Layer / ## Full-stack headings.

Includes 4 new tests: uniform scope derivation, mixed scope grouping,
single scope header, and prReviewScope override prevention.

For provenance purposes, this commit was AI assisted.

* fix(server): add tree-discovered stack fallback to pr-switch handler

The initial-load path upgrades prStackInfo for root PRs when the
stack tree reveals descendants, but the pr-switch handler was missing
this logic. The server now sends correct prStackInfo after switching
to a root-of-stack PR. Both Bun and Pi.

Also removes stale prReviewScope dependency from agent job panel's
copyAllText useMemo.

For provenance purposes, this commit was AI assisted.

* fix: extract resolveStackInfo helper, fix stack UI on non-stacked PRs

Extracts the tree-discovered stack fallback into resolveStackInfo()
in pr-stack.ts — eliminates 4 copies of the same logic across Bun
startup, Bun pr-switch, Pi startup, and Pi pr-switch.

Fixes StackedPRLabel showing on every PR: the check now counts
non-default-branch nodes (> 1) instead of all nodes (> 1). Without
this, every PR showed a "Stack (1 PR)" popover because the tree
always has at least [defaultBranch, currentPR].

For provenance purposes, this commit was AI assisted.

* fix(review-editor): don't re-open already-succeeded PR tabs on retry

On partial failure retry, openUrls was pre-seeded with URLs from
previously succeeded targets, causing those PR pages to re-open
in the browser alongside newly succeeded ones. Now starts empty —
only URLs from the current attempt are opened.

For provenance purposes, this commit was AI assisted.

* refactor(review-editor): extract PR session state into usePRSession hook

Consolidates 5 independent useState calls (prMetadata, prStackInfo,
prStackTree, prDiffScope, prDiffScopeOptions) into a single
usePRSession hook with atomic updatePRSession callback.

Replaces two identical 5-line setter blocks (initial load and
applyPRResponse) with single updatePRSession calls. All ~60 consumer
sites unchanged — same variable names via destructuring.

For provenance purposes, this commit was AI assisted.
2026-04-27 22:07:55 -07:00

516 lines
16 KiB
TypeScript

/**
* Agent Jobs — Pi (node:http) server handler.
*
* Manages background agent processes (spawn, monitor, kill) and exposes
* HTTP routes + SSE broadcasting for job status updates.
*
* Mirrors packages/server/agent-jobs.ts but uses node:http primitives.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { spawn, execFileSync, type ChildProcess } from "node:child_process";
import {
type AgentJobInfo,
type AgentJobEvent,
type AgentCapability,
type AgentCapabilities,
isTerminalStatus,
jobSource,
serializeAgentSSEEvent,
AGENT_HEARTBEAT_COMMENT,
AGENT_HEARTBEAT_INTERVAL_MS,
} from "../generated/agent-jobs.js";
import { formatClaudeLogEvent } from "../generated/claude-review.js";
import { json, parseBody } from "./helpers.js";
// ---------------------------------------------------------------------------
// Route prefixes
// ---------------------------------------------------------------------------
const BASE = "/api/agents";
const JOBS = `${BASE}/jobs`;
const JOBS_STREAM = `${JOBS}/stream`;
const CAPABILITIES = `${BASE}/capabilities`;
// ---------------------------------------------------------------------------
// which() helper for Node.js
// ---------------------------------------------------------------------------
function whichCmd(cmd: string): boolean {
try {
const bin = process.platform === "win32" ? "where" : "which";
execFileSync(bin, [cmd], { encoding: "utf-8", stdio: ["pipe", "pipe", "pipe"] });
return true;
} catch {
return false;
}
}
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export interface AgentJobHandlerOptions {
mode: "plan" | "review" | "annotate";
getServerUrl: () => string;
getCwd: () => string;
/** Server-side command builder for known providers (codex, claude, tour). */
buildCommand?: (provider: string, config?: Record<string, unknown>) => Promise<{
command: string[];
outputPath?: string;
captureStdout?: boolean;
stdinPrompt?: string;
cwd?: string;
prompt?: string;
label?: string;
/** Underlying engine used (e.g., "claude" or "codex"). Stored on AgentJobInfo for UI display. */
engine?: string;
/** Model used (e.g., "sonnet", "opus"). Stored on AgentJobInfo for UI display. */
model?: string;
/** Claude --effort level. */
effort?: string;
/** Codex reasoning effort level. */
reasoningEffort?: string;
/** Whether Codex fast mode was enabled. */
fastMode?: boolean;
/** PR URL at launch time. */
prUrl?: string;
/** PR diff scope at launch time. */
diffScope?: string;
/** Diff context snapshot at launch (stored on AgentJobInfo for per-job "Copy All"). */
diffContext?: AgentJobInfo["diffContext"];
} | null>;
/** Called when a job completes successfully — parse results and push annotations. */
onJobComplete?: (job: AgentJobInfo, meta: { outputPath?: string; stdout?: string; cwd?: string }) => void | Promise<void>;
}
export function createAgentJobHandler(options: AgentJobHandlerOptions) {
const { mode, getServerUrl, getCwd } = options;
// --- State ---
const jobs = new Map<string, { info: AgentJobInfo; proc: ChildProcess | null }>();
const jobOutputPaths = new Map<string, string>();
const subscribers = new Set<ServerResponse>();
let version = 0;
// --- Capability detection (run once) ---
const capabilities: AgentCapability[] = [
{ id: "claude", name: "Claude Code", available: whichCmd("claude") },
{ id: "codex", name: "Codex CLI", available: whichCmd("codex") },
{ id: "tour", name: "Code Tour", available: whichCmd("claude") || whichCmd("codex") },
];
const capabilitiesResponse: AgentCapabilities = {
mode,
providers: capabilities,
available: capabilities.some((c) => c.available),
};
// --- SSE broadcasting ---
function broadcast(event: AgentJobEvent): void {
version++;
const data = serializeAgentSSEEvent(event);
for (const res of subscribers) {
try {
res.write(data);
} catch {
subscribers.delete(res);
}
}
}
// --- Process lifecycle ---
function spawnJob(
provider: string,
command: string[],
label: string,
outputPath?: string,
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"] },
): AgentJobInfo {
const id = crypto.randomUUID();
const source = jobSource(id);
const info: AgentJobInfo = {
id,
source,
provider,
label,
status: "starting",
startedAt: Date.now(),
command,
cwd: getCwd(),
...(spawnOptions?.engine && { engine: spawnOptions.engine }),
...(spawnOptions?.model && { model: spawnOptions.model }),
...(spawnOptions?.effort && { effort: spawnOptions.effort }),
...(spawnOptions?.reasoningEffort && { reasoningEffort: spawnOptions.reasoningEffort }),
...(spawnOptions?.fastMode && { fastMode: spawnOptions.fastMode }),
...(spawnOptions?.prUrl && { prUrl: spawnOptions.prUrl }),
...(spawnOptions?.diffScope && { diffScope: spawnOptions.diffScope }),
...(spawnOptions?.diffContext && { diffContext: spawnOptions.diffContext }),
};
let proc: ChildProcess | null = null;
try {
const spawnCwd = spawnOptions?.cwd ?? getCwd();
const captureStdout = spawnOptions?.captureStdout ?? false;
const hasStdinPrompt = !!spawnOptions?.stdinPrompt;
proc = spawn(command[0], command.slice(1), {
cwd: spawnCwd,
stdio: [
hasStdinPrompt ? "pipe" : "ignore",
captureStdout ? "pipe" : "ignore",
"pipe",
],
env: {
...process.env,
PLANNOTATOR_AGENT_SOURCE: source,
PLANNOTATOR_API_URL: getServerUrl(),
},
});
// Write prompt to stdin and close (for providers that read prompt from stdin)
if (hasStdinPrompt && proc.stdin) {
proc.stdin.write(spawnOptions!.stdinPrompt!);
proc.stdin.end();
}
info.status = "running";
info.cwd = spawnCwd;
if (spawnOptions?.prompt) info.prompt = spawnOptions.prompt;
jobs.set(id, { info, proc });
if (outputPath) jobOutputPaths.set(id, outputPath);
if (spawnOptions?.cwd) jobOutputPaths.set(`${id}:cwd`, spawnOptions.cwd);
broadcast({ type: "job:started", job: { ...info } });
// --- Stdout capture (Claude JSONL streaming) ---
let stdoutBuf = "";
if (captureStdout && proc.stdout) {
proc.stdout.on("data", (chunk: Buffer) => {
const text = chunk.toString();
stdoutBuf += text;
// Forward JSONL lines as log events
const lines = text.split('\n');
for (const line of lines) {
if (!line.trim()) continue;
// Tour jobs with the Claude engine also stream Claude JSONL.
if (provider === "claude" || spawnOptions?.engine === "claude") {
const formatted = formatClaudeLogEvent(line);
if (formatted !== null) {
broadcast({ type: "job:log", jobId: id, delta: formatted + '\n' });
}
continue;
}
try {
const event = JSON.parse(line);
if (event.type === 'result') continue;
} catch { /* not JSON — forward as raw log */ }
broadcast({ type: "job:log", jobId: id, delta: line + '\n' });
}
});
}
// --- Stderr: buffer tail for errors + live log streaming ---
let stderrBuf = "";
let logPending = "";
let logFlushTimer: ReturnType<typeof setTimeout> | null = null;
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString();
stderrBuf = (stderrBuf + text).slice(-500);
logPending += text;
if (!logFlushTimer) {
logFlushTimer = setTimeout(() => {
if (logPending) {
broadcast({ type: "job:log", jobId: id, delta: logPending });
logPending = "";
}
logFlushTimer = null;
}, 200);
}
});
}
// Monitor process close (fires after stdio streams are fully drained,
// unlike 'exit' which fires before — critical for stdout capture)
proc.on("close", async (exitCode) => {
// Flush remaining stderr
if (logFlushTimer) { clearTimeout(logFlushTimer); logFlushTimer = null; }
if (logPending) {
broadcast({ type: "job:log", jobId: id, delta: logPending });
logPending = "";
}
const entry = jobs.get(id);
if (!entry || isTerminalStatus(entry.info.status)) return;
entry.info.endedAt = Date.now();
entry.info.exitCode = exitCode ?? undefined;
entry.info.status = exitCode === 0 ? "done" : "failed";
if (exitCode !== 0 && stderrBuf) {
entry.info.error = stderrBuf;
}
// Ingest results before broadcasting completion
const jobOutputPath = jobOutputPaths.get(id);
const jobCwd = jobOutputPaths.get(`${id}:cwd`);
if (exitCode === 0 && options.onJobComplete) {
try {
await options.onJobComplete(entry.info, {
outputPath: jobOutputPath,
stdout: captureStdout ? stdoutBuf : undefined,
cwd: jobCwd,
});
} catch {
// Result ingestion failure shouldn't prevent job completion broadcast
}
}
jobOutputPaths.delete(id);
jobOutputPaths.delete(`${id}:cwd`);
broadcast({ type: "job:completed", job: { ...entry.info } });
});
// Handle spawn errors after process starts
proc.on("error", (err) => {
const entry = jobs.get(id);
if (!entry || isTerminalStatus(entry.info.status)) return;
entry.info.status = "failed";
entry.info.endedAt = Date.now();
entry.info.error = err.message;
broadcast({ type: "job:completed", job: { ...entry.info } });
});
} catch (err) {
jobs.set(id, { info, proc: null });
broadcast({ type: "job:started", job: { ...info } });
info.status = "failed";
info.endedAt = Date.now();
info.error = err instanceof Error ? err.message : String(err);
broadcast({ type: "job:completed", job: { ...info } });
}
return { ...info };
}
function killJob(id: string): boolean {
const entry = jobs.get(id);
if (!entry || isTerminalStatus(entry.info.status)) return false;
if (entry.proc) {
try {
entry.proc.kill();
} catch {
// Process may have already exited
}
}
entry.info.status = "killed";
entry.info.endedAt = Date.now();
jobOutputPaths.delete(id);
jobOutputPaths.delete(`${id}:cwd`);
broadcast({ type: "job:completed", job: { ...entry.info } });
return true;
}
function killAll(): number {
let count = 0;
for (const [id, entry] of jobs) {
if (!isTerminalStatus(entry.info.status)) {
killJob(id);
count++;
}
}
return count;
}
function getAllJobs(): AgentJobInfo[] {
return Array.from(jobs.values()).map((e) => ({ ...e.info }));
}
// --- HTTP handler ---
return {
killAll,
async handle(
req: IncomingMessage,
res: ServerResponse,
url: URL,
): Promise<boolean> {
// --- GET /api/agents/capabilities ---
if (url.pathname === CAPABILITIES && req.method === "GET") {
json(res, capabilitiesResponse);
return true;
}
// --- SSE stream ---
if (url.pathname === JOBS_STREAM && req.method === "GET") {
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
res.setTimeout(0);
// Send current state as snapshot
const snapshot: AgentJobEvent = {
type: "snapshot",
jobs: getAllJobs(),
};
res.write(serializeAgentSSEEvent(snapshot));
subscribers.add(res);
// Heartbeat to keep connection alive
const heartbeatTimer = setInterval(() => {
try {
res.write(AGENT_HEARTBEAT_COMMENT);
} catch {
clearInterval(heartbeatTimer);
subscribers.delete(res);
}
}, AGENT_HEARTBEAT_INTERVAL_MS);
// Clean up on disconnect
res.on("close", () => {
clearInterval(heartbeatTimer);
subscribers.delete(res);
});
return true;
}
// --- GET /api/agents/jobs (snapshot / polling fallback) ---
if (url.pathname === JOBS && req.method === "GET") {
const since = url.searchParams.get("since");
if (since !== null) {
const sinceVersion = parseInt(since, 10);
if (!isNaN(sinceVersion) && sinceVersion === version) {
res.writeHead(304);
res.end();
return true;
}
}
json(res, { jobs: getAllJobs(), version });
return true;
}
// --- POST /api/agents/jobs (launch) ---
if (url.pathname === JOBS && req.method === "POST") {
try {
const body = await parseBody(req);
const provider = typeof body.provider === "string" ? body.provider : "";
let rawCommand = Array.isArray(body.command) ? body.command : [];
let command = rawCommand.filter((c: unknown): c is string => typeof c === "string");
let label = typeof body.label === "string" ? body.label : `${provider} agent`;
let outputPath: string | undefined;
// Validate provider is a known, available capability
const cap = capabilities.find((c) => c.id === provider);
if (!cap || !cap.available) {
json(res, { error: `Unknown or unavailable provider: ${provider}` }, 400);
return true;
}
// Try server-side command building for known providers
let captureStdout = false;
let stdinPrompt: string | undefined;
let spawnCwd: string | undefined;
let promptText: string | undefined;
let jobEngine: string | undefined;
let jobModel: string | undefined;
let jobEffort: string | undefined;
let jobReasoningEffort: string | undefined;
let jobFastMode: boolean | undefined;
let jobPrUrl: string | undefined;
let jobDiffScope: string | undefined;
let jobDiffContext: AgentJobInfo["diffContext"] | undefined;
if (options.buildCommand) {
// Thread config from POST body to buildCommand
const config: Record<string, unknown> = {};
if (typeof body.engine === "string") config.engine = body.engine;
if (typeof body.model === "string") config.model = body.model;
if (typeof body.reasoningEffort === "string") config.reasoningEffort = body.reasoningEffort;
if (typeof body.effort === "string") config.effort = body.effort;
if (body.fastMode === true) config.fastMode = true;
const built = await options.buildCommand(provider, Object.keys(config).length > 0 ? config : undefined);
if (built) {
command = built.command;
outputPath = built.outputPath;
captureStdout = built.captureStdout ?? false;
stdinPrompt = built.stdinPrompt;
spawnCwd = built.cwd;
promptText = built.prompt;
if (built.label) label = built.label;
jobEngine = built.engine;
jobModel = built.model;
jobEffort = built.effort;
jobReasoningEffort = built.reasoningEffort;
jobFastMode = built.fastMode;
jobPrUrl = built.prUrl;
jobDiffScope = built.diffScope;
jobDiffContext = built.diffContext;
}
}
if (command.length === 0) {
json(res, { error: 'Missing "command" array' }, 400);
return true;
}
const job = spawnJob(provider, command, label, outputPath, {
captureStdout,
stdinPrompt,
cwd: spawnCwd,
prompt: promptText,
engine: jobEngine,
model: jobModel,
effort: jobEffort,
reasoningEffort: jobReasoningEffort,
fastMode: jobFastMode,
prUrl: jobPrUrl,
diffScope: jobDiffScope,
diffContext: jobDiffContext,
});
json(res, { job }, 201);
} catch {
json(res, { error: "Invalid JSON" }, 400);
}
return true;
}
// --- DELETE /api/agents/jobs/:id (kill one) ---
if (url.pathname.startsWith(JOBS + "/") && url.pathname !== JOBS_STREAM && req.method === "DELETE") {
const id = url.pathname.slice(JOBS.length + 1);
if (!id) {
json(res, { error: "Missing job ID" }, 400);
return true;
}
const found = killJob(id);
if (!found) {
json(res, { error: "Job not found or already terminal" }, 404);
return true;
}
json(res, { ok: true });
return true;
}
// --- DELETE /api/agents/jobs (kill all) ---
if (url.pathname === JOBS && req.method === "DELETE") {
const count = killAll();
json(res, { ok: true, killed: count });
return true;
}
// Not handled
return false;
},
};
}