feat: agentic review — background job runner with SSE streaming (#443)

* feat(server): add agent job manager with process lifecycle and SSE streaming

Introduces the backend for agentic review — a server-agnostic job runner
that spawns agent processes, monitors their lifecycle via proc.exited, and
broadcasts status updates over a dedicated SSE stream.

New files:
- packages/shared/agent-jobs.ts: runtime-agnostic types, SSE helpers, state machine
- packages/server/agent-jobs.ts: Bun HTTP adapter with spawn/kill/SSE/REST routes

Wired into the review server with late-bound serverUrl, killAll on stop
and process exit for defense-in-depth cleanup. Capability detection via
Bun.which() for Claude Code, Codex CLI, and shell.

For provenance purposes, this commit was AI assisted.

* feat(ui): add Review Agents tab with job management and live status

Adds the client-side UI for agentic review:
- useAgentJobs hook: SSE primary with polling fallback, capabilities fetch
- AgentsTab: shared component with provider dropdown, job cards, kill controls
- ReviewAgentsIcon: magnifying glass with cog icon
- ReviewPanel: new "Review Agents" tab conditional on detected capabilities
- App.tsx: initializes hook and passes props to ReviewPanel

Jobs show live elapsed time, status badges, annotation counts per source,
and expandable error details. Stub command (sleep 10-30s) for initial testing.

For provenance purposes, this commit was AI assisted.

* fix(server): narrow proc.stderr type to satisfy TS

Bun types proc.stderr as ReadableStream | number. Add typeof guard
to narrow before passing to Response constructor.

For provenance purposes, this commit was AI assisted.

* fix(server): prevent stdout deadlock, stderr pipe stall, and worktree cwd mismatch

- Change stdout from "pipe" to "ignore" — agents post findings via the
  annotations API, not stdout. Unconsumed pipe buffer caused deadlock.
- Drain stderr continuously instead of reading after exit — prevents
  pipe-full stall on chatty agents.
- Make agent getCwd worktree-aware using the same currentDiffType check
  the AI endpoints use, so agents spawn in the correct checkout.
- Exclude 'agents' tab from PR context fetch guard in ReviewPanel.

For provenance purposes, this commit was AI assisted.

* feat(pi): mirror agent-job endpoints in Pi review server

Add apps/pi-extension/server/agent-jobs.ts — a Node.js port of the Bun
agent-jobs handler using child_process.spawn, res.write() SSE, and
execFileSync for capability detection.

Wire into serverReview.ts: route delegation, killAll in stop(),
process.on("exit") guard, worktree-aware getCwd, and serverUrl
late-binding. Add agent-jobs to vendor.sh for shared type vendoring.

For provenance purposes, this commit was AI assisted.

* fix(agents): harden agent jobs — listener leak, provider allowlist, Windows support, docs

- Use process.once instead of process.on to prevent exit listener accumulation
- Validate provider against known capabilities before spawning (rejects unknown providers)
- Use platform-aware which/where for CLI detection on Windows
- Broadcast jobs:cleared event from kill-all endpoint (was dead client code)
- Document all /api/agents/* endpoints in CLAUDE.md Review Server table

For provenance purposes, this commit was AI assisted.

* fix(agents): SSE ref reset, jobs:cleared divergence, exit listener cleanup

- Reset receivedSnapshotRef/fallbackRef on re-mount so polling fallback
  activates correctly on subsequent tab switches
- Remove jobs:cleared broadcast from kill-all — killAll() already fires
  individual job:completed events, so the UI shows status transitions
  instead of going blank
- Store exit handler ref so stop() can removeListener, preventing
  accumulation in long-lived hosts (OpenCode, Pi)

For provenance purposes, this commit was AI assisted.
This commit is contained in:
Michael Ramos
2026-03-31 10:06:18 -07:00
committed by GitHub
parent ab9cae3488
commit d8a67beb1c
14 changed files with 1417 additions and 7 deletions
+6
View File
@@ -233,6 +233,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) | | `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
| `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) | | `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) |
| `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all | | `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all |
| `/api/agents/capabilities` | GET | Check available agent providers (claude, codex, shell) |
| `/api/agents/jobs/stream` | GET | SSE stream for real-time agent job status updates |
| `/api/agents/jobs` | GET | Snapshot of agent jobs (polling fallback, `?since=N` for version gating) |
| `/api/agents/jobs` | POST | Launch an agent job (body: `{ provider, command, label }`) |
| `/api/agents/jobs` | DELETE | Kill all running agent jobs |
| `/api/agents/jobs/:id` | DELETE | Kill a specific agent job |
### Annotate Server (`packages/server/annotate.ts`) ### Annotate Server (`packages/server/annotate.ts`)
+331
View File
@@ -0,0 +1,331 @@
/**
* 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 { 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;
}
export function createAgentJobHandler(options: AgentJobHandlerOptions) {
const { mode, getServerUrl, getCwd } = options;
// --- State ---
const jobs = new Map<string, { info: AgentJobInfo; proc: ChildProcess | null }>();
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: "shell", name: "Shell Command", available: true },
];
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,
): AgentJobInfo {
const id = crypto.randomUUID();
const source = jobSource(id);
const info: AgentJobInfo = {
id,
source,
provider,
label,
status: "starting",
startedAt: Date.now(),
command,
};
let proc: ChildProcess | null = null;
try {
proc = spawn(command[0], command.slice(1), {
cwd: getCwd(),
stdio: ["ignore", "ignore", "pipe"],
env: {
...process.env,
PLANNOTATOR_AGENT_SOURCE: source,
PLANNOTATOR_API_URL: getServerUrl(),
},
});
info.status = "running";
jobs.set(id, { info, proc });
broadcast({ type: "job:started", job: { ...info } });
// Accumulate stderr continuously (must attach before exit fires)
let stderrBuf = "";
if (proc.stderr) {
proc.stderr.on("data", (chunk: Buffer) => {
stderrBuf = (stderrBuf + chunk.toString()).slice(-500);
});
}
// Monitor process exit
proc.on("exit", (exitCode) => {
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;
}
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();
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 : "shell";
const rawCommand = Array.isArray(body.command) ? body.command : [];
const command = rawCommand.filter((c: unknown): c is string => typeof c === "string");
const label = typeof body.label === "string" ? body.label : `${provider} agent`;
// 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;
}
if (command.length === 0) {
json(res, { error: 'Missing "command" array' }, 400);
return true;
}
const job = spawnJob(provider, command, label);
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;
},
};
}
+24 -1
View File
@@ -36,6 +36,7 @@ import {
} from "../generated/review-core.js"; } from "../generated/review-core.js";
import { createEditorAnnotationHandler } from "./annotations.js"; import { createEditorAnnotationHandler } from "./annotations.js";
import { createAgentJobHandler } from "./agent-jobs.js";
import { createExternalAnnotationHandler } from "./external-annotations.js"; import { createExternalAnnotationHandler } from "./external-annotations.js";
import { import {
handleDraftRequest, handleDraftRequest,
@@ -145,10 +146,25 @@ export async function startReviewServer(options: {
: getRepoInfo(); : getRepoInfo();
const editorAnnotations = createEditorAnnotationHandler(); const editorAnnotations = createEditorAnnotationHandler();
const externalAnnotations = createExternalAnnotationHandler("review"); const externalAnnotations = createExternalAnnotationHandler("review");
let currentPatch = options.rawPatch; let currentPatch = options.rawPatch;
let currentGitRef = options.gitRef; let currentGitRef = options.gitRef;
let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentDiffType: DiffType = options.diffType || "uncommitted";
let currentError = options.error; let currentError = options.error;
// Agent jobs — background process manager (late-binds serverUrl via getter)
let serverUrl = "";
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
getCwd: () => {
if (currentDiffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(currentDiffType);
if (parsed) return parsed.path;
}
return options.gitContext?.cwd ?? process.cwd();
},
});
const sharingEnabled = const sharingEnabled =
options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled"; options.sharingEnabled ?? process.env.PLANNOTATOR_SHARE !== "disabled";
const shareBaseUrl = const shareBaseUrl =
@@ -519,6 +535,8 @@ export async function startReviewServer(options: {
return; return;
} else if (await externalAnnotations.handle(req, res, url)) { } else if (await externalAnnotations.handle(req, res, url)) {
return; return;
} else if (await agentJobs.handle(req, res, url)) {
return;
} else if (aiEndpoints && url.pathname.startsWith("/api/ai/")) { } else if (aiEndpoints && url.pathname.startsWith("/api/ai/")) {
const handler = aiEndpoints[url.pathname]; const handler = aiEndpoints[url.pathname];
if (handler) { if (handler) {
@@ -568,13 +586,18 @@ export async function startReviewServer(options: {
}); });
const { port, portSource } = await listenOnPort(server); const { port, portSource } = await listenOnPort(server);
serverUrl = `http://localhost:${port}`;
const exitHandler = () => agentJobs.killAll();
process.once("exit", exitHandler);
return { return {
port, port,
portSource, portSource,
url: `http://localhost:${port}`, url: serverUrl,
waitForDecision: () => decisionPromise, waitForDecision: () => decisionPromise,
stop: () => { stop: () => {
process.removeListener("exit", exitHandler);
agentJobs.killAll();
aiSessionManager?.disposeAll(); aiSessionManager?.disposeAll();
aiRegistry?.disposeAll(); aiRegistry?.disposeAll();
server.close(); server.close();
+1 -1
View File
@@ -6,7 +6,7 @@ cd "$(dirname "$0")"
mkdir -p generated generated/ai/providers mkdir -p generated generated/ai/providers
for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation; do for f in feedback-templates review-core storage draft project pr-provider pr-github pr-gitlab checklist integrations-common repo reference-common favicon resolve-file config external-annotation agent-jobs; do
src="../../packages/shared/$f.ts" src="../../packages/shared/$f.ts"
printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts" printf '// @generated — DO NOT EDIT. Source: packages/shared/%s.ts\n' "$f" | cat - "$src" > "generated/$f.ts"
done done
+8
View File
@@ -29,6 +29,7 @@ import { extractLinesFromPatch } from './utils/patchParser';
import { isTypingTarget, useReviewSearch } from './hooks/useReviewSearch'; import { isTypingTarget, useReviewSearch } from './hooks/useReviewSearch';
import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations'; import { useEditorAnnotations } from '@plannotator/ui/hooks/useEditorAnnotations';
import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations'; import { useExternalAnnotations } from '@plannotator/ui/hooks/useExternalAnnotations';
import { useAgentJobs } from '@plannotator/ui/hooks/useAgentJobs';
import { exportEditorAnnotations } from '@plannotator/ui/utils/parser'; import { exportEditorAnnotations } from '@plannotator/ui/utils/parser';
import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle'; import { ResizeHandle } from '@plannotator/ui/components/ResizeHandle';
import { DiffViewer } from './components/DiffViewer'; import { DiffViewer } from './components/DiffViewer';
@@ -215,6 +216,7 @@ const ReviewApp: React.FC = () => {
// The same !!origin proxy is used elsewhere in this file (draft hook, feedback guard, conditional UI) // The same !!origin proxy is used elsewhere in this file (draft hook, feedback guard, conditional UI)
// so this should be addressed as a broader refactor. // so this should be addressed as a broader refactor.
const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations<CodeAnnotation>({ enabled: !!origin }); const { externalAnnotations, updateExternalAnnotation, deleteExternalAnnotation } = useExternalAnnotations<CodeAnnotation>({ enabled: !!origin });
const agentJobs = useAgentJobs({ enabled: !!origin });
// Merge local + SSE annotations, deduping draft-restored externals against // Merge local + SSE annotations, deduping draft-restored externals against
// live SSE versions. Prefer the SSE version when both exist (same source, // live SSE versions. Prefer the SSE version when both exist (same source,
@@ -1568,6 +1570,12 @@ const ReviewApp: React.FC = () => {
aiConfig={aiConfig} aiConfig={aiConfig}
onAIConfigChange={handleAIConfigChange} onAIConfigChange={handleAIConfigChange}
hasAISession={!!aiChat.sessionId} hasAISession={!!aiChat.sessionId}
agentJobs={agentJobs.jobs}
agentCapabilities={agentJobs.capabilities}
onAgentLaunch={agentJobs.launchJob}
onAgentKillJob={agentJobs.killJob}
onAgentKillAll={agentJobs.killAll}
externalAnnotations={externalAnnotations}
/> />
</div> </div>
@@ -0,0 +1,18 @@
import React from 'react';
interface ReviewAgentsIconProps {
className?: string;
}
/**
* Magnifying glass with cog — icon for the Review Agents tab.
* Uses currentColor for stroke so it inherits from parent text color.
*/
export const ReviewAgentsIcon: React.FC<ReviewAgentsIconProps> = ({ className = 'w-3.5 h-3.5' }) => (
<svg className={`${className} flex-shrink-0`} viewBox="0 0 64 64" fill="none" stroke="currentColor">
<path d="M40.5 40.5L55 55" strokeWidth={7} strokeLinecap="round" />
<circle cx="27" cy="27" r="20" strokeWidth={4} />
<path d="M27 14L29.2 14.3L30.2 17.2L32.6 18.6L35.5 17.5L37.2 19.2L36.1 22.1L37.5 24.5L40.4 25.5L40.7 27.7L40.4 29.9L37.5 30.9L36.1 33.3L37.2 36.2L35.5 37.9L32.6 36.8L30.2 38.2L29.2 41.1L27 41.4L24.8 41.1L23.8 38.2L21.4 36.8L18.5 37.9L16.8 36.2L17.9 33.3L16.5 30.9L13.6 29.9L13.3 27.7L13.6 25.5L16.5 24.5L17.9 22.1L16.8 19.2L18.5 17.5L21.4 18.6L23.8 17.2L24.8 14.3Z" strokeWidth={1.5} strokeLinejoin="round" />
<circle cx="27" cy="27.7" r="7" strokeWidth={1.5} />
</svg>
);
@@ -12,11 +12,14 @@ import { PRCommentsTab } from './PRCommentsTab';
import { PRChecksTab } from './PRChecksTab'; import { PRChecksTab } from './PRChecksTab';
import { AITab } from './AITab'; import { AITab } from './AITab';
import { SparklesIcon } from './SparklesIcon'; import { SparklesIcon } from './SparklesIcon';
import { ReviewAgentsIcon } from './ReviewAgentsIcon';
import { AgentsTab } from '@plannotator/ui/components/AgentsTab';
import type { PRMetadata } from '@plannotator/shared/pr-provider'; import type { PRMetadata } from '@plannotator/shared/pr-provider';
import type { AIChatEntry } from '../hooks/useAIChat'; import type { AIChatEntry } from '../hooks/useAIChat';
import type { AgentJobInfo, AgentCapabilities } from '@plannotator/ui/types';
import type { DiffFile } from '../types'; import type { DiffFile } from '../types';
type ReviewPanelTab = 'annotations' | 'ai' | 'summary' | 'comments' | 'checks'; type ReviewPanelTab = 'annotations' | 'ai' | 'agents' | 'summary' | 'comments' | 'checks';
interface ReviewPanelProps { interface ReviewPanelProps {
isOpen: boolean; isOpen: boolean;
@@ -48,6 +51,13 @@ interface ReviewPanelProps {
aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null }; aiConfig?: { providerId: string | null; model: string | null; reasoningEffort?: string | null };
onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void; onAIConfigChange?: (config: { providerId?: string | null; model?: string | null; reasoningEffort?: string | null }) => void;
hasAISession?: boolean; hasAISession?: boolean;
// Agent props
agentJobs?: AgentJobInfo[];
agentCapabilities?: AgentCapabilities | null;
onAgentLaunch?: (params: { provider?: string; command?: string[]; label?: string }) => void;
onAgentKillJob?: (id: string) => void;
onAgentKillAll?: () => void;
externalAnnotations?: Array<{ source?: string }>;
} }
const SuggestionPreview: React.FC<{ code: string; originalCode?: string; language?: string }> = ({ code, originalCode, language }) => { const SuggestionPreview: React.FC<{ code: string; originalCode?: string; language?: string }> = ({ code, originalCode, language }) => {
@@ -147,11 +157,19 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
aiConfig, aiConfig,
onAIConfigChange, onAIConfigChange,
hasAISession, hasAISession,
agentJobs,
agentCapabilities,
onAgentLaunch,
onAgentKillJob,
onAgentKillAll,
externalAnnotations,
}) => { }) => {
const totalCount = annotations.length + (editorAnnotations?.length ?? 0); const totalCount = annotations.length + (editorAnnotations?.length ?? 0);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState<ReviewPanelTab>('annotations'); const [activeTab, setActiveTab] = useState<ReviewPanelTab>('annotations');
const hasPRTabs = !!prMetadata; const hasPRTabs = !!prMetadata;
const hasAgents = !!agentCapabilities?.available;
const runningAgentCount = (agentJobs ?? []).filter(j => j.status === 'running' || j.status === 'starting').length;
// Allow parent to control the active tab (e.g., switch to AI tab on ask) // Allow parent to control the active tab (e.g., switch to AI tab on ask)
useEffect(() => { useEffect(() => {
@@ -164,7 +182,7 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
const handleTabChange = (tab: ReviewPanelTab) => { const handleTabChange = (tab: ReviewPanelTab) => {
setActiveTab(tab); setActiveTab(tab);
onTabChange?.(tab); onTabChange?.(tab);
if (tab !== 'annotations' && tab !== 'ai' && !prContext && !isPRContextLoading) { if (tab !== 'annotations' && tab !== 'ai' && tab !== 'agents' && !prContext && !isPRContextLoading) {
fetchContext(); fetchContext();
} }
}; };
@@ -213,13 +231,18 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
<div className="px-3 flex items-center border-b border-border/50" style={{ height: 'var(--panel-header-h)' }}> <div className="px-3 flex items-center border-b border-border/50" style={{ height: 'var(--panel-header-h)' }}>
<div className="flex items-center gap-2 w-full"> <div className="flex items-center gap-2 w-full">
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground"> <h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{activeTab === 'annotations' ? 'Annotations' : activeTab === 'ai' ? 'AI' : activeTab === 'summary' ? 'Summary' : activeTab === 'comments' ? 'Comments' : 'Checks'} {activeTab === 'annotations' ? 'Annotations' : activeTab === 'ai' ? 'AI' : activeTab === 'agents' ? 'Review Agents' : activeTab === 'summary' ? 'Summary' : activeTab === 'comments' ? 'Comments' : 'Checks'}
</h2> </h2>
{activeTab === 'annotations' && totalCount > 0 && ( {activeTab === 'annotations' && totalCount > 0 && (
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground"> <span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
{totalCount} {totalCount}
</span> </span>
)} )}
{activeTab === 'agents' && (agentJobs?.length ?? 0) > 0 && (
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
{agentJobs!.length}
</span>
)}
{activeTab === 'ai' && aiMessages.length > 0 && ( {activeTab === 'ai' && aiMessages.length > 0 && (
<span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground"> <span className="text-[10px] font-mono bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
{aiMessages.length} {aiMessages.length}
@@ -260,6 +283,24 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
</button> </button>
)} )}
{/* Agents tab (visible when agent capabilities available) */}
{hasAgents && (
<button
onClick={() => handleTabChange('agents')}
className={`relative flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium transition-colors ${
activeTab === 'agents'
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:text-foreground hover:bg-muted/50'
}`}
title="Review Agents"
>
<ReviewAgentsIcon />
{runningAgentCount > 0 && activeTab !== 'agents' && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-primary animate-pulse" />
)}
</button>
)}
{/* PR tabs (only in PR mode) */} {/* PR tabs (only in PR mode) */}
{hasPRTabs && PR_TABS.filter(t => t.id !== 'annotations').map((tab) => ( {hasPRTabs && PR_TABS.filter(t => t.id !== 'annotations').map((tab) => (
<button <button
@@ -415,8 +456,20 @@ export const ReviewPanel: React.FC<ReviewPanelProps> = ({
/> />
)} )}
{/* Agents tab */}
{activeTab === 'agents' && (
<AgentsTab
jobs={agentJobs ?? []}
capabilities={agentCapabilities ?? null}
onLaunch={onAgentLaunch ?? (() => {})}
onKillJob={onAgentKillJob ?? (() => {})}
onKillAll={onAgentKillAll ?? (() => {})}
externalAnnotations={externalAnnotations ?? []}
/>
)}
{/* PR tabs — loading / error / content */} {/* PR tabs — loading / error / content */}
{activeTab !== 'annotations' && activeTab !== 'ai' && ( {activeTab !== 'annotations' && activeTab !== 'ai' && activeTab !== 'agents' && (
<> <>
{isPRContextLoading && ( {isPRContextLoading && (
<div className="flex items-center justify-center h-40"> <div className="flex items-center justify-center h-40">
+337
View File
@@ -0,0 +1,337 @@
/**
* Agent Jobs — Bun server handler.
*
* Manages background agent processes (spawn, monitor, kill) and exposes
* HTTP routes + SSE broadcasting for job status updates.
*
* Mirrors packages/server/external-annotations.ts in structure.
* Server-agnostic: takes a mode, server URL getter, and cwd getter.
*/
import {
type AgentJobInfo,
type AgentJobEvent,
type AgentCapability,
type AgentCapabilities,
isTerminalStatus,
jobSource,
serializeAgentSSEEvent,
AGENT_HEARTBEAT_COMMENT,
AGENT_HEARTBEAT_INTERVAL_MS,
} from "@plannotator/shared/agent-jobs";
export type { AgentJobInfo, AgentJobEvent, AgentCapabilities } from "@plannotator/shared/agent-jobs";
// ---------------------------------------------------------------------------
// Handler interface
// ---------------------------------------------------------------------------
export interface AgentJobHandler {
handle: (
req: Request,
url: URL,
options?: { disableIdleTimeout?: () => void },
) => Promise<Response | null>;
/** Kill all running jobs — call on server shutdown. */
killAll: () => void;
}
// ---------------------------------------------------------------------------
// Route prefixes
// ---------------------------------------------------------------------------
const BASE = "/api/agents";
const JOBS = `${BASE}/jobs`;
const JOBS_STREAM = `${JOBS}/stream`;
const CAPABILITIES = `${BASE}/capabilities`;
// ---------------------------------------------------------------------------
// Factory
// ---------------------------------------------------------------------------
export interface AgentJobHandlerOptions {
/** Which server mode this handler is mounted in. */
mode: "plan" | "review" | "annotate";
/** Returns the server's base URL (e.g., "http://localhost:12345"). Late-bound. */
getServerUrl: () => string;
/** Returns the working directory for spawned processes. */
getCwd: () => string;
}
export function createAgentJobHandler(options: AgentJobHandlerOptions): AgentJobHandler {
const { mode, getServerUrl, getCwd } = options;
// --- State ---
const jobs = new Map<string, { info: AgentJobInfo; proc: ReturnType<typeof Bun.spawn> | null }>();
const subscribers = new Set<ReadableStreamDefaultController>();
const encoder = new TextEncoder();
let version = 0;
// --- Capability detection (run once) ---
const capabilities: AgentCapability[] = [
{ id: "claude", name: "Claude Code", available: !!Bun.which("claude") },
{ id: "codex", name: "Codex CLI", available: !!Bun.which("codex") },
{ id: "shell", name: "Shell Command", available: true },
];
const capabilitiesResponse: AgentCapabilities = {
mode,
providers: capabilities,
available: capabilities.some((c) => c.available),
};
// --- SSE broadcasting ---
function broadcast(event: AgentJobEvent): void {
version++;
const data = encoder.encode(serializeAgentSSEEvent(event));
for (const controller of subscribers) {
try {
controller.enqueue(data);
} catch {
subscribers.delete(controller);
}
}
}
// --- Process lifecycle ---
function spawnJob(
provider: string,
command: string[],
label: string,
): AgentJobInfo {
const id = crypto.randomUUID();
const source = jobSource(id);
const info: AgentJobInfo = {
id,
source,
provider,
label,
status: "starting",
startedAt: Date.now(),
command,
};
let proc: ReturnType<typeof Bun.spawn> | null = null;
try {
proc = Bun.spawn(command, {
cwd: getCwd(),
stdout: "ignore",
stderr: "pipe",
env: {
...process.env,
PLANNOTATOR_AGENT_SOURCE: source,
PLANNOTATOR_API_URL: getServerUrl(),
},
});
info.status = "running";
jobs.set(id, { info, proc });
broadcast({ type: "job:started", job: { ...info } });
// Drain stderr continuously to prevent pipe-full deadlock
let stderrBuf = "";
if (proc.stderr && typeof proc.stderr !== "number") {
(async () => {
try {
const reader = proc!.stderr as ReadableStream;
for await (const chunk of reader) {
const text = typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk);
stderrBuf = (stderrBuf + text).slice(-500);
}
} catch {
// Stream closed or already consumed
}
})();
}
// Monitor process exit
proc.exited.then((exitCode) => {
const entry = jobs.get(id);
if (!entry || isTerminalStatus(entry.info.status)) return;
entry.info.endedAt = Date.now();
entry.info.exitCode = exitCode;
entry.info.status = exitCode === 0 ? "done" : "failed";
if (exitCode !== 0 && stderrBuf) {
entry.info.error = stderrBuf;
}
broadcast({ type: "job:completed", job: { ...entry.info } });
});
} catch (err) {
// Spawn itself failed (e.g., command not found).
// Broadcast started (so hook adds the job), then completed (so it updates to failed).
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();
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: Request,
url: URL,
handlerOptions?: { disableIdleTimeout?: () => void },
): Promise<Response | null> {
// --- GET /api/agents/capabilities ---
if (url.pathname === CAPABILITIES && req.method === "GET") {
return Response.json(capabilitiesResponse);
}
// --- SSE stream ---
if (url.pathname === JOBS_STREAM && req.method === "GET") {
handlerOptions?.disableIdleTimeout?.();
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
let ctrl: ReadableStreamDefaultController;
const stream = new ReadableStream({
start(controller) {
ctrl = controller;
// Send current state as snapshot
const snapshot: AgentJobEvent = {
type: "snapshot",
jobs: getAllJobs(),
};
controller.enqueue(encoder.encode(serializeAgentSSEEvent(snapshot)));
subscribers.add(controller);
// Heartbeat to keep connection alive
heartbeatTimer = setInterval(() => {
try {
controller.enqueue(encoder.encode(AGENT_HEARTBEAT_COMMENT));
} catch {
if (heartbeatTimer) clearInterval(heartbeatTimer);
subscribers.delete(controller);
}
}, AGENT_HEARTBEAT_INTERVAL_MS);
},
cancel() {
if (heartbeatTimer) clearInterval(heartbeatTimer);
subscribers.delete(ctrl);
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
});
}
// --- 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) {
return new Response(null, { status: 304 });
}
}
return Response.json({ jobs: getAllJobs(), version });
}
// --- POST /api/agents/jobs (launch) ---
if (url.pathname === JOBS && req.method === "POST") {
try {
const body = await req.json();
const provider = typeof body.provider === "string" ? body.provider : "shell";
const rawCommand = Array.isArray(body.command) ? body.command : [];
const command = rawCommand.filter((c: unknown): c is string => typeof c === "string");
const label = typeof body.label === "string" ? body.label : `${provider} agent`;
// Validate provider is a known, available capability
const cap = capabilities.find((c) => c.id === provider);
if (!cap || !cap.available) {
return Response.json(
{ error: `Unknown or unavailable provider: ${provider}` },
{ status: 400 },
);
}
if (command.length === 0) {
return Response.json(
{ error: 'Missing "command" array' },
{ status: 400 },
);
}
const job = spawnJob(provider, command, label);
return Response.json({ job }, { status: 201 });
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
}
// --- 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) {
return Response.json({ error: "Missing job ID" }, { status: 400 });
}
const found = killJob(id);
if (!found) {
return Response.json({ error: "Job not found or already terminal" }, { status: 404 });
}
return Response.json({ ok: true });
}
// --- DELETE /api/agents/jobs (kill all) ---
if (url.pathname === JOBS && req.method === "DELETE") {
const count = killAll();
return Response.json({ ok: true, killed: count });
}
// Not handled
return null;
},
};
}
+26 -1
View File
@@ -17,6 +17,7 @@ import { handleImage, handleUpload, handleAgents, handleServerReady, handleDraft
import { contentHash, deleteDraft } from "./draft"; import { contentHash, deleteDraft } from "./draft";
import { createEditorAnnotationHandler } from "./editor-annotations"; import { createEditorAnnotationHandler } from "./editor-annotations";
import { createExternalAnnotationHandler } from "./external-annotations"; import { createExternalAnnotationHandler } from "./external-annotations";
import { createAgentJobHandler } from "./agent-jobs";
import { saveConfig, detectGitUser, getServerConfig } from "./config"; import { saveConfig, detectGitUser, getServerConfig } from "./config";
import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, getPRUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr"; import { type PRMetadata, type PRReviewFileComment, fetchPRFileContent, fetchPRContext, submitPRReview, fetchPRViewedFiles, markPRFilesViewed, getPRUser, prRefFromMetadata, getDisplayRepo, getMRLabel, getMRNumberLabel } from "./pr";
import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai"; import { createAIEndpoints, ProviderRegistry, SessionManager, createProvider, type AIEndpoints, type PiSDKConfig } from "@plannotator/ai";
@@ -105,6 +106,20 @@ export async function startReviewServer(
let currentDiffType: DiffType = options.diffType || "uncommitted"; let currentDiffType: DiffType = options.diffType || "uncommitted";
let currentError = options.error; let currentError = options.error;
// Agent jobs — background process manager (late-binds serverUrl via getter)
let serverUrl = "";
const agentJobs = createAgentJobHandler({
mode: "review",
getServerUrl: () => serverUrl,
getCwd: () => {
if (currentDiffType.startsWith("worktree:")) {
const parsed = parseWorktreeDiffType(currentDiffType);
if (parsed) return parsed.path;
}
return gitContext?.cwd ?? process.cwd();
},
});
// AI provider setup (graceful — AI features degrade if SDK unavailable) // AI provider setup (graceful — AI features degrade if SDK unavailable)
const aiRegistry = new ProviderRegistry(); const aiRegistry = new ProviderRegistry();
const aiSessionManager = new SessionManager(); const aiSessionManager = new SessionManager();
@@ -448,6 +463,12 @@ export async function startReviewServer(
}); });
if (externalResponse) return externalResponse; if (externalResponse) return externalResponse;
// API: Agent jobs (background review agents)
const agentResponse = await agentJobs.handle(req, url, {
disableIdleTimeout: () => server.timeout(req, 0),
});
if (agentResponse) return agentResponse;
// API: Submit review feedback // API: Submit review feedback
if (url.pathname === "/api/feedback" && req.method === "POST") { if (url.pathname === "/api/feedback" && req.method === "POST") {
try { try {
@@ -570,7 +591,9 @@ export async function startReviewServer(
} }
const port = server.port!; const port = server.port!;
const serverUrl = `http://localhost:${port}`; serverUrl = `http://localhost:${port}`;
const exitHandler = () => agentJobs.killAll();
process.once("exit", exitHandler);
// Notify caller that server is ready // Notify caller that server is ready
if (onReady) { if (onReady) {
@@ -583,6 +606,8 @@ export async function startReviewServer(
isRemote, isRemote,
waitForDecision: () => decisionPromise, waitForDecision: () => decisionPromise,
stop: () => { stop: () => {
process.removeListener("exit", exitHandler);
agentJobs.killAll();
aiSessionManager.disposeAll(); aiSessionManager.disposeAll();
aiRegistry.disposeAll(); aiRegistry.disposeAll();
server.stop(); server.stop();
+91
View File
@@ -0,0 +1,91 @@
/**
* Agent Jobs — shared types, state machine, and SSE helpers.
*
* Runtime-agnostic: no node:fs, no node:http, no Bun APIs.
* Both the Bun server handler and (future) Node handler import
* this module and wrap it with their respective HTTP transport layers.
*
* Mirrors packages/shared/external-annotation.ts in structure.
*/
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export type AgentJobStatus = "starting" | "running" | "done" | "failed" | "killed";
export interface AgentJobInfo {
/** Unique job identifier (UUID). */
id: string;
/** Source identifier for external annotations — "agent-{id prefix}". */
source: string;
/** Provider that spawned this job — "claude", "codex", "shell", etc. */
provider: string;
/** Human-readable label for the job. */
label: string;
/** Current lifecycle status. */
status: AgentJobStatus;
/** Timestamp when the job was created. */
startedAt: number;
/** Timestamp when the job reached a terminal state. */
endedAt?: number;
/** Process exit code (set on done/failed). */
exitCode?: number;
/** Last ~500 chars of stderr on failure. */
error?: string;
/** The actual command that was spawned (for display/debug). */
command: string[];
}
export interface AgentCapability {
id: string;
name: string;
available: boolean;
}
export interface AgentCapabilities {
mode: "plan" | "review" | "annotate";
providers: AgentCapability[];
/** True if at least one provider is available. */
available: boolean;
}
// ---------------------------------------------------------------------------
// SSE event types
// ---------------------------------------------------------------------------
export type AgentJobEvent =
| { type: "snapshot"; jobs: AgentJobInfo[] }
| { type: "job:started"; job: AgentJobInfo }
| { type: "job:updated"; job: AgentJobInfo }
| { type: "job:completed"; job: AgentJobInfo }
| { type: "jobs:cleared" };
// ---------------------------------------------------------------------------
// SSE helpers
// ---------------------------------------------------------------------------
/** Heartbeat comment to keep SSE connections alive (sent every 30s). */
export const AGENT_HEARTBEAT_COMMENT = ":\n\n";
/** Interval in ms between heartbeat comments. */
export const AGENT_HEARTBEAT_INTERVAL_MS = 30_000;
/** Encode an event as an SSE `data:` line. */
export function serializeAgentSSEEvent(event: AgentJobEvent): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Check if a status is terminal (no further transitions). */
export function isTerminalStatus(status: AgentJobStatus): boolean {
return status === "done" || status === "failed" || status === "killed";
}
/** Generate the source identifier for a job from its ID. */
export function jobSource(id: string): string {
return "agent-" + id.slice(0, 8);
}
+1
View File
@@ -20,6 +20,7 @@
"./favicon": "./favicon.ts", "./favicon": "./favicon.ts",
"./resolve-file": "./resolve-file.ts", "./resolve-file": "./resolve-file.ts",
"./external-annotation": "./external-annotation.ts", "./external-annotation": "./external-annotation.ts",
"./agent-jobs": "./agent-jobs.ts",
"./config": "./config.ts" "./config": "./config.ts"
} }
} }
+316
View File
@@ -0,0 +1,316 @@
import React, { useState, useEffect, useRef, useMemo } from 'react';
import type { AgentJobInfo, AgentCapabilities } from '../types';
import { isTerminalStatus } from '@plannotator/shared/agent-jobs';
interface AgentsTabProps {
jobs: AgentJobInfo[];
capabilities: AgentCapabilities | null;
onLaunch: (params: { provider?: string; command?: string[]; label?: string }) => void;
onKillJob: (id: string) => void;
onKillAll: () => void;
externalAnnotations: Array<{ source?: string }>;
}
// --- Duration display ---
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
return `${minutes}m ${remainingSeconds}s`;
}
function ElapsedTime({ startedAt }: { startedAt: number }) {
const [, setTick] = useState(0);
useEffect(() => {
const timer = setInterval(() => setTick((t) => t + 1), 1000);
return () => clearInterval(timer);
}, []);
return <>{formatDuration(Date.now() - startedAt)}</>;
}
// --- Status badge ---
function StatusBadge({ status }: { status: AgentJobInfo['status'] }) {
switch (status) {
case 'starting':
case 'running':
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-primary">
<svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{status === 'starting' ? 'Starting' : 'Running'}
</span>
);
case 'done':
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-success">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
Done
</span>
);
case 'failed':
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-destructive">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Failed
</span>
);
case 'killed':
return (
<span className="inline-flex items-center gap-1 text-[10px] font-medium text-muted-foreground">
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M9 10a1 1 0 011-1h4a1 1 0 110 2h-4a1 1 0 01-1-1z" />
</svg>
Killed
</span>
);
}
}
// --- Provider badge ---
function ProviderBadge({ provider }: { provider: string }) {
const label =
provider === 'claude' ? 'Claude' :
provider === 'codex' ? 'Codex' :
'Shell';
return (
<span className="text-[9px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded bg-muted text-muted-foreground">
{label}
</span>
);
}
// --- Job card ---
function JobCard({
job,
annotationCount,
onKill,
expanded,
onToggle,
}: {
job: AgentJobInfo;
annotationCount: number;
onKill: () => void;
expanded: boolean;
onToggle: () => void;
}) {
const isTerminal = isTerminalStatus(job.status);
return (
<div
className={`group relative p-2.5 rounded-lg border transition-all ${
expanded
? 'bg-muted/30 border-border/50'
: 'border-transparent hover:bg-muted/30 hover:border-border/50'
} ${isTerminal ? 'cursor-pointer' : ''}`}
onClick={isTerminal ? onToggle : undefined}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<ProviderBadge provider={job.provider} />
<span className="text-xs text-foreground/80 truncate">{job.label}</span>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
{annotationCount > 0 && (
<span className="text-[10px] font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">
{annotationCount}
</span>
)}
<span className="text-[10px] text-muted-foreground/60 font-mono">
{isTerminal && job.endedAt
? formatDuration(job.endedAt - job.startedAt)
: <ElapsedTime startedAt={job.startedAt} />
}
</span>
</div>
</div>
<div className="flex items-center justify-between mt-1.5">
<StatusBadge status={job.status} />
{!isTerminal && (
<button
onClick={(e) => {
e.stopPropagation();
onKill();
}}
className="p-1 rounded-md opacity-0 group-hover:opacity-100 hover:bg-destructive/10 text-muted-foreground hover:text-destructive transition-all"
title="Kill agent"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
)}
</div>
{/* Error details */}
{job.status === 'failed' && job.error && expanded && (
<div className="mt-2 p-2 rounded bg-destructive/5 border border-destructive/20">
<pre className="text-[10px] text-destructive/80 whitespace-pre-wrap break-all font-mono leading-relaxed max-h-24 overflow-y-auto">
{job.error}
</pre>
</div>
)}
</div>
);
}
// --- Main component ---
export const AgentsTab: React.FC<AgentsTabProps> = ({
jobs,
capabilities,
onLaunch,
onKillJob,
onKillAll,
externalAnnotations,
}) => {
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [expandedJobId, setExpandedJobId] = useState<string | null>(null);
const initializedRef = useRef(false);
// Set default provider once capabilities load
useEffect(() => {
if (capabilities && !initializedRef.current) {
const firstAvailable = capabilities.providers.find((p) => p.available);
if (firstAvailable) {
setSelectedProvider(firstAvailable.id);
initializedRef.current = true;
}
}
}, [capabilities]);
const availableProviders = useMemo(
() => capabilities?.providers.filter((p) => p.available) ?? [],
[capabilities],
);
// Annotation counts per job source
const annotationCounts = useMemo(() => {
const counts = new Map<string, number>();
for (const ann of externalAnnotations) {
if (ann.source) {
counts.set(ann.source, (counts.get(ann.source) ?? 0) + 1);
}
}
return counts;
}, [externalAnnotations]);
// Sort: running first, then by startedAt descending
const sortedJobs = useMemo(() => {
return [...jobs].sort((a, b) => {
const aRunning = !isTerminalStatus(a.status);
const bRunning = !isTerminalStatus(b.status);
if (aRunning !== bRunning) return aRunning ? -1 : 1;
return b.startedAt - a.startedAt;
});
}, [jobs]);
const runningCount = useMemo(
() => jobs.filter((j) => !isTerminalStatus(j.status)).length,
[jobs],
);
const handleLaunch = () => {
if (!selectedProvider) return;
const provider = availableProviders.find((p) => p.id === selectedProvider);
onLaunch({
provider: selectedProvider,
// Stub command — will be replaced with real agent invocation
command: ['sleep', String(10 + Math.floor(Math.random() * 21))],
label: provider ? `${provider.name}` : selectedProvider,
});
};
return (
<div className="flex flex-col h-full">
{/* Launch bar */}
{availableProviders.length > 0 && (
<div className="p-2 border-b border-border/30">
<div className="flex items-center gap-1.5">
{availableProviders.length > 1 ? (
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
className="flex-1 text-[11px] px-2 py-1.5 rounded-md bg-muted/50 border border-border/50 text-foreground focus:outline-none focus:ring-1 focus:ring-primary/30"
>
{availableProviders.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
) : (
<span className="flex-1 text-[11px] px-2 py-1.5 text-muted-foreground">
{availableProviders[0]?.name}
</span>
)}
<button
onClick={handleLaunch}
disabled={!selectedProvider}
className="px-3 py-1.5 rounded-md text-[11px] font-medium bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Run Agent
</button>
</div>
</div>
)}
{/* Job list */}
<div className="flex-1 overflow-y-auto p-2 space-y-1">
{sortedJobs.length === 0 ? (
<div className="flex flex-col items-center justify-center h-40 text-center px-4">
<div className="w-10 h-10 rounded-full bg-muted/50 flex items-center justify-center mb-3">
<svg className="w-5 h-5 text-muted-foreground" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<p className="text-xs text-muted-foreground">
No agent jobs yet
</p>
<p className="text-[10px] text-muted-foreground/60 mt-1">
Launch an agent to get automated review findings
</p>
</div>
) : (
sortedJobs.map((job) => (
<JobCard
key={job.id}
job={job}
annotationCount={annotationCounts.get(job.source) ?? 0}
onKill={() => onKillJob(job.id)}
expanded={expandedJobId === job.id}
onToggle={() => setExpandedJobId(expandedJobId === job.id ? null : job.id)}
/>
))
)}
</div>
{/* Kill All footer */}
{runningCount >= 2 && (
<div className="p-2 border-t border-border/50">
<button
onClick={onKillAll}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg text-xs font-medium text-destructive hover:bg-destructive/10 transition-all"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
Kill All ({runningCount})
</button>
</div>
)}
</div>
);
};
+193
View File
@@ -0,0 +1,193 @@
/**
* Real-time agent job state via SSE with polling fallback.
*
* Primary transport: EventSource on /api/agents/jobs/stream.
* Fallback: version-gated GET polling if SSE fails.
*
* Mirrors packages/ui/hooks/useExternalAnnotations.ts in structure.
*
* Gated by an `enabled` option — callers pass their API-mode signal
* to avoid SSE/polling in static or demo contexts where there is no server.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import type { AgentJobInfo, AgentJobEvent, AgentCapabilities } from '../types';
const POLL_INTERVAL_MS = 500;
const STREAM_URL = '/api/agents/jobs/stream';
const JOBS_URL = '/api/agents/jobs';
const CAPABILITIES_URL = '/api/agents/capabilities';
interface UseAgentJobsReturn {
jobs: AgentJobInfo[];
capabilities: AgentCapabilities | null;
launchJob: (params: { provider?: string; command?: string[]; label?: string }) => Promise<AgentJobInfo | null>;
killJob: (id: string) => Promise<void>;
killAll: () => Promise<void>;
}
export function useAgentJobs(
options?: { enabled?: boolean },
): UseAgentJobsReturn {
const enabled = options?.enabled ?? true;
const [jobs, setJobs] = useState<AgentJobInfo[]>([]);
const [capabilities, setCapabilities] = useState<AgentCapabilities | null>(null);
const versionRef = useRef(0);
const fallbackRef = useRef(false);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const receivedSnapshotRef = useRef(false);
// Fetch capabilities once on mount
useEffect(() => {
if (!enabled) return;
fetch(CAPABILITIES_URL)
.then((res) => res.json())
.then((data) => {
if (data && Array.isArray(data.providers)) {
setCapabilities(data as AgentCapabilities);
}
})
.catch(() => {
// Silent — capabilities unavailable
});
}, [enabled]);
// SSE + polling for job state
useEffect(() => {
if (!enabled) return;
let cancelled = false;
receivedSnapshotRef.current = false;
fallbackRef.current = false;
// --- SSE primary transport ---
const es = new EventSource(STREAM_URL);
es.onmessage = (event) => {
if (cancelled) return;
try {
const parsed: AgentJobEvent = JSON.parse(event.data);
switch (parsed.type) {
case 'snapshot':
receivedSnapshotRef.current = true;
setJobs(parsed.jobs);
break;
case 'job:started':
setJobs((prev) => [...prev, parsed.job]);
break;
case 'job:updated':
case 'job:completed':
setJobs((prev) =>
prev.map((j) => (j.id === parsed.job.id ? parsed.job : j)),
);
break;
case 'jobs:cleared':
// No-op: killAll() already broadcasts individual job:completed events
// for each killed job, so the UI updates incrementally.
break;
}
} catch {
// Ignore malformed events (e.g., heartbeat comments)
}
};
es.onerror = () => {
// If we never received a snapshot, SSE isn't working — fall back to polling
if (!receivedSnapshotRef.current && !fallbackRef.current) {
fallbackRef.current = true;
es.close();
startPolling();
}
// Otherwise, EventSource will auto-reconnect and we'll get a fresh snapshot
};
// --- Polling fallback ---
function startPolling() {
if (cancelled) return;
fetchSnapshot();
pollTimerRef.current = setInterval(() => {
if (cancelled) return;
fetchSnapshot();
}, POLL_INTERVAL_MS);
}
async function fetchSnapshot() {
try {
const url =
versionRef.current > 0
? `${JOBS_URL}?since=${versionRef.current}`
: JOBS_URL;
const res = await fetch(url);
if (res.status === 304) return;
if (!res.ok) return;
const data = await res.json();
if (Array.isArray(data.jobs)) {
setJobs(data.jobs);
}
if (typeof data.version === 'number') {
versionRef.current = data.version;
}
} catch {
// Silent — next poll will retry
}
}
return () => {
cancelled = true;
es.close();
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
};
}, [enabled]);
const launchJob = useCallback(
async (params: {
provider?: string;
command?: string[];
label?: string;
}): Promise<AgentJobInfo | null> => {
try {
const res = await fetch(JOBS_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params),
});
if (!res.ok) return null;
const data = await res.json();
return data.job ?? null;
} catch {
return null;
}
},
[],
);
const killJob = useCallback(async (id: string) => {
try {
await fetch(`${JOBS_URL}/${encodeURIComponent(id)}`, {
method: 'DELETE',
});
} catch {
// SSE will reconcile
}
}, []);
const killAll = useCallback(async () => {
try {
await fetch(JOBS_URL, { method: 'DELETE' });
} catch {
// SSE will reconcile
}
}, []);
return { jobs, capabilities, launchJob, killJob, killAll };
}
+8
View File
@@ -138,3 +138,11 @@ export type { EditorAnnotation } from '@plannotator/shared/types';
export type { export type {
ExternalAnnotationEvent, ExternalAnnotationEvent,
} from '@plannotator/shared/external-annotation'; } from '@plannotator/shared/external-annotation';
export type {
AgentJobInfo,
AgentJobEvent,
AgentJobStatus,
AgentCapability,
AgentCapabilities,
} from '@plannotator/shared/agent-jobs';