From c993a6fc4d8f51f2deb494b7cab3bb130d8afa01 Mon Sep 17 00:00:00 2001 From: Virang Jhaveri Date: Tue, 12 May 2026 00:31:54 +0530 Subject: [PATCH 1/2] feat(core): add "Run with agent" action for inspector comments Adds a Vite dev-server plugin exposing /__superconnector/* endpoints for starting, polling, and canceling agent runs scoped to a single comment. The inspector comment widget gains a "Run with agent" button that dispatches the comment through @nimrobo/superconnector to a connected coding agent, with live status (running / done / error / canceled) streamed back over HMR. Localized strings added for en, ja, zh-CN, zh-TW. --- .changeset/superconnector-integration.md | 5 + packages/core/package.json | 1 + .../inspector/comment-superconnect.tsx | 280 ++++++++++++++++++ .../components/inspector/comment-widget.tsx | 24 +- packages/core/src/locale/en.ts | 6 + packages/core/src/locale/ja.ts | 6 + packages/core/src/locale/types.ts | 6 + packages/core/src/locale/zh-cn.ts | 6 + packages/core/src/locale/zh-tw.ts | 6 + packages/core/src/vite/config.ts | 2 + .../src/vite/superconnector-plugin.test.ts | 51 ++++ .../core/src/vite/superconnector-plugin.ts | 245 +++++++++++++++ pnpm-lock.yaml | 9 + 13 files changed, 638 insertions(+), 9 deletions(-) create mode 100644 .changeset/superconnector-integration.md create mode 100644 packages/core/src/app/components/inspector/comment-superconnect.tsx create mode 100644 packages/core/src/vite/superconnector-plugin.test.ts create mode 100644 packages/core/src/vite/superconnector-plugin.ts diff --git a/.changeset/superconnector-integration.md b/.changeset/superconnector-integration.md new file mode 100644 index 00000000..6215afec --- /dev/null +++ b/.changeset/superconnector-integration.md @@ -0,0 +1,5 @@ +--- +'@open-slide/core': minor +--- + +Add a "Run with agent" action to inspector comments. Each comment is dispatched through `@nimrobo/superconnector` to a connected coding agent, with run status streamed back to the UI over HMR. diff --git a/packages/core/package.json b/packages/core/package.json index 3866d0b0..b5193fa5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -62,6 +62,7 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/geist": "^5.2.8", + "@nimrobo/superconnector": "^0.1.0", "@tailwindcss/vite": "^4.2.2", "@vitejs/plugin-react": "^4.3.3", "chalk": "^5.3.0", diff --git a/packages/core/src/app/components/inspector/comment-superconnect.tsx b/packages/core/src/app/components/inspector/comment-superconnect.tsx new file mode 100644 index 00000000..2fedec72 --- /dev/null +++ b/packages/core/src/app/components/inspector/comment-superconnect.tsx @@ -0,0 +1,280 @@ +import { CheckCheck, Play, StopCircle, XCircle } from 'lucide-react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; +import { useLocale } from '@/lib/use-locale'; + +export type AgentStatus = 'idle' | 'pending' | 'done' | 'error' | 'canceled'; + +type ActiveStatus = Exclude; + +type SuperconnectorEvent = { + runId: string; + sessionId?: string; + commentId: string; + status: ActiveStatus; + msgType: string; + text: string; +}; + +type RunStatus = { + status: ActiveStatus; + sessionId?: string; + error?: string; + messages?: Array<{ text: string }>; +}; + +async function checkAgentAvailable(): Promise { + try { + const res = await fetch('/__superconnector/available'); + if (!res.ok) return false; + const data = (await res.json()) as { available?: boolean }; + return data.available === true; + } catch { + return false; + } +} + +async function startAgentRun( + slideId: string, + commentId: string, + line: number, + note: string, +): Promise { + const res = await fetch('/__superconnector/runs', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ slideId, commentId, line, note }), + }); + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(body.error ?? `agent run failed: ${res.status}`); + } + const data = (await res.json()) as { runId: string }; + return data.runId; +} + +async function pollStatus(runId: string): Promise { + const res = await fetch(`/__superconnector/runs/${runId}`); + if (!res.ok) throw new Error(`status poll failed: ${res.status}`); + return (await res.json()) as RunStatus; +} + +async function cancelAgentRun(runId: string): Promise { + const res = await fetch(`/__superconnector/runs/${runId}/cancel`, { method: 'POST' }); + if (!res.ok) throw new Error(`cancel failed: ${res.status}`); +} + +export type AgentRuns = { + available: boolean; + statusOf: (commentId: string) => AgentStatus; + logOf: (commentId: string) => string | undefined; + run: (commentId: string, line: number, note: string) => Promise; + cancel: (commentId: string) => Promise; +}; + +export function useAgentRuns(slideId: string, onDone: () => void | Promise): AgentRuns { + const t = useLocale(); + const [available, setAvailable] = useState(false); + const [status, setStatusMap] = useState>(new Map()); + const [log, setLogMap] = useState>(new Map()); + const [runs, setRunsMap] = useState>(new Map()); + const pollTimers = useRef>>(new Map()); + + useEffect(() => { + checkAgentAvailable().then(setAvailable); + }, []); + + useEffect(() => { + return () => { + for (const timer of pollTimers.current.values()) clearInterval(timer); + }; + }, []); + + const setStatus = useCallback((id: string, s: AgentStatus) => { + setStatusMap((prev) => new Map(prev).set(id, s)); + }, []); + + const stopPolling = useCallback((id: string) => { + const timer = pollTimers.current.get(id); + if (timer !== undefined) { + clearInterval(timer); + pollTimers.current.delete(id); + } + }, []); + + const finishRun = useCallback( + (commentId: string, s: ActiveStatus, text?: string) => { + stopPolling(commentId); + setStatus(commentId, s); + if (text) setLogMap((prev) => new Map(prev).set(commentId, text)); + if (s === 'done') { + toast.success(t.inspector.agentDone); + void onDone(); + } else if (s === 'error') { + toast.error(text ? `${t.inspector.agentError}: ${text}` : t.inspector.agentError); + } else if (s === 'canceled') { + toast(t.inspector.agentCanceled); + } + }, + [ + onDone, + setStatus, + stopPolling, + t.inspector.agentCanceled, + t.inspector.agentDone, + t.inspector.agentError, + ], + ); + + useEffect(() => { + if (!import.meta.hot) return; + const handler = (data: SuperconnectorEvent) => { + const { commentId, msgType, status: s, text } = data; + if (msgType === 'done') { + finishRun(commentId, 'done'); + } else if (msgType === 'error') { + finishRun(commentId, 'error', text); + } else if (msgType === 'canceled') { + finishRun(commentId, 'canceled'); + } else if (text) { + setLogMap((prev) => new Map(prev).set(commentId, text)); + setStatus(commentId, s); + } + }; + import.meta.hot.on('open-slide:superconnector-event', handler); + return () => import.meta.hot?.off('open-slide:superconnector-event', handler); + }, [finishRun, setStatus]); + + const run = useCallback( + async (commentId: string, line: number, note: string) => { + setStatus(commentId, 'pending'); + toast(t.inspector.agentRunning, { icon: '▶' }); + let runId: string; + try { + runId = await startAgentRun(slideId, commentId, line, note); + setRunsMap((prev) => new Map(prev).set(commentId, runId)); + } catch { + setStatus(commentId, 'error'); + toast.error(t.inspector.agentError); + return; + } + + const timer = setInterval(async () => { + try { + const result = await pollStatus(runId); + if (result.messages?.length) { + const last = result.messages.at(-1); + if (last?.text) setLogMap((prev) => new Map(prev).set(commentId, last.text)); + } + if (result.status !== 'pending') { + finishRun(commentId, result.status, result.error); + } + } catch { + finishRun(commentId, 'error'); + } + }, 2000); + pollTimers.current.set(commentId, timer); + }, + [finishRun, setStatus, slideId, t.inspector.agentError, t.inspector.agentRunning], + ); + + const cancel = useCallback( + async (commentId: string) => { + const runId = runs.get(commentId); + if (!runId) return; + try { + await cancelAgentRun(runId); + } catch { + toast.error(t.inspector.agentError); + } + }, + [runs, t.inspector.agentError], + ); + + return { + available, + statusOf: (id) => status.get(id) ?? 'idle', + logOf: (id) => log.get(id), + run, + cancel, + }; +} + +export function AgentLogLine({ commentId, runs }: { commentId: string; runs: AgentRuns }) { + const status = runs.statusOf(commentId); + const text = runs.logOf(commentId); + if (!text) return null; + if (status !== 'pending' && status !== 'done') return null; + return
{text}
; +} + +export function AgentRunButton({ + commentId, + line, + note, + runs, +}: { + commentId: string; + line: number; + note: string; + runs: AgentRuns; +}) { + const t = useLocale(); + if (!runs.available) return null; + const status = runs.statusOf(commentId); + + if (status === 'pending') { + return ( + + ); + } + if (status === 'done') { + return ( + + + + ); + } + if (status === 'error') { + return ( + + ); + } + if (status === 'canceled') { + return ( + + ); + } + return ( + + ); +} diff --git a/packages/core/src/app/components/inspector/comment-widget.tsx b/packages/core/src/app/components/inspector/comment-widget.tsx index 1fcd9da0..b0bd026c 100644 --- a/packages/core/src/app/components/inspector/comment-widget.tsx +++ b/packages/core/src/app/components/inspector/comment-widget.tsx @@ -1,12 +1,14 @@ import { MessageSquare, Trash2, X } from 'lucide-react'; import { useState } from 'react'; import { format, plural, useLocale } from '@/lib/use-locale'; +import { AgentLogLine, AgentRunButton, useAgentRuns } from './comment-superconnect'; import { useInspector } from './inspector-provider'; export function CommentWidget() { const t = useLocale(); - const { comments, remove, error } = useInspector(); + const { comments, remove, error, slideId, refetch } = useInspector(); const [open, setOpen] = useState(false); + const agents = useAgentRuns(slideId, refetch); const count = comments.length; return ( @@ -43,15 +45,19 @@ export function CommentWidget() { {format(t.inspector.commentLineLabel, { n: c.line })}
{c.note}
+ + +
+ +
- ))} diff --git a/packages/core/src/locale/en.ts b/packages/core/src/locale/en.ts index d59108b7..1fb3e434 100644 --- a/packages/core/src/locale/en.ts +++ b/packages/core/src/locale/en.ts @@ -220,6 +220,12 @@ export const en: Locale = { commentsApplyHintPrefix: 'Run ', commentsApplyHintSuffix: ' in your agent to apply these.', commentDeleteAria: 'Delete', + runWithAgent: 'Run with agent (Superconnected)', + agentRunning: 'Agent is running…', + agentDone: 'Done', + agentError: 'Agent error', + agentCanceled: 'Agent stopped', + stopAgent: 'Stop agent', saveFailed: "Couldn't save:", }, diff --git a/packages/core/src/locale/ja.ts b/packages/core/src/locale/ja.ts index 6fad1c64..f28c04ae 100644 --- a/packages/core/src/locale/ja.ts +++ b/packages/core/src/locale/ja.ts @@ -222,6 +222,12 @@ export const ja: Locale = { commentsApplyHintPrefix: 'エージェントで ', commentsApplyHintSuffix: ' を実行して適用してください。', commentDeleteAria: '削除', + runWithAgent: 'エージェントで実行 (Superconnected)', + agentRunning: 'エージェント実行中…', + agentDone: '完了', + agentError: 'エージェントエラー', + agentCanceled: 'エージェントを停止しました', + stopAgent: 'エージェントを停止', saveFailed: '保存に失敗しました:', }, diff --git a/packages/core/src/locale/types.ts b/packages/core/src/locale/types.ts index 3d14336c..8fd1f5f1 100644 --- a/packages/core/src/locale/types.ts +++ b/packages/core/src/locale/types.ts @@ -219,6 +219,12 @@ export type Locale = { commentsApplyHintPrefix: string; commentsApplyHintSuffix: string; commentDeleteAria: string; + runWithAgent: string; + agentRunning: string; + agentDone: string; + agentError: string; + agentCanceled: string; + stopAgent: string; /** Prefix for the toast shown when one or more buffered edits fail to write to disk. */ saveFailed: string; }; diff --git a/packages/core/src/locale/zh-cn.ts b/packages/core/src/locale/zh-cn.ts index d1abcea8..27f0837c 100644 --- a/packages/core/src/locale/zh-cn.ts +++ b/packages/core/src/locale/zh-cn.ts @@ -220,6 +220,12 @@ export const zhCN: Locale = { commentsApplyHintPrefix: '在你的代理中运行 ', commentsApplyHintSuffix: ' 以应用这些更改。', commentDeleteAria: '删除', + runWithAgent: '用 Agent 执行 (Superconnected)', + agentRunning: 'Agent 执行中…', + agentDone: '完成', + agentError: 'Agent 出错', + agentCanceled: 'Agent 已停止', + stopAgent: '停止 Agent', saveFailed: '保存失败:', }, diff --git a/packages/core/src/locale/zh-tw.ts b/packages/core/src/locale/zh-tw.ts index 54d9884b..d4efac8c 100644 --- a/packages/core/src/locale/zh-tw.ts +++ b/packages/core/src/locale/zh-tw.ts @@ -220,6 +220,12 @@ export const zhTW: Locale = { commentsApplyHintPrefix: '在你的代理中執行 ', commentsApplyHintSuffix: ' 以套用這些變更。', commentDeleteAria: '刪除', + runWithAgent: '用 Agent 執行 (Superconnected)', + agentRunning: 'Agent 執行中…', + agentDone: '完成', + agentError: 'Agent 發生錯誤', + agentCanceled: 'Agent 已停止', + stopAgent: '停止 Agent', saveFailed: '儲存失敗:', }, diff --git a/packages/core/src/vite/config.ts b/packages/core/src/vite/config.ts index a7d461c9..7719f78a 100644 --- a/packages/core/src/vite/config.ts +++ b/packages/core/src/vite/config.ts @@ -11,6 +11,7 @@ import { filesPlugin } from './files-plugin.ts'; import { locTagsPlugin } from './loc-tags-plugin.ts'; import { notesPlugin } from './notes-plugin.ts'; import { loadUserConfig, type OpenSlideConfig, openSlidePlugin } from './open-slide-plugin.ts'; +import { superconnectorPlugin } from './superconnector-plugin.ts'; function findPackageRoot(fromFile: string): string { let dir = path.dirname(fromFile); @@ -50,6 +51,7 @@ export async function createViteConfig(opts: CreateViteConfigOptions): Promise { + it('uses a stable app id and slide selector', () => { + expect(superconnectorAppId()).toBe('open-slide'); + expect(superconnectorSessionSelector('launch-deck')).toBe('slide:launch-deck'); + }); +}); + +describe('buildSuperconnectorPrompt', () => { + it('points the agent at the commented slide file and marker', () => { + const prompt = buildSuperconnectorPrompt({ + slidesDir: 'slides', + slideId: 'launch-deck', + commentId: 'c-deadbeef', + line: 42, + note: 'Make the headline shorter.', + }); + + expect(prompt).toContain('slides/launch-deck/index.tsx'); + expect(prompt).toContain('at line 42'); + expect(prompt).toContain('"Make the headline shorter."'); + expect(prompt).toContain('id="c-deadbeef"'); + }); + + it('uses custom slide directories', () => { + const prompt = buildSuperconnectorPrompt({ + slidesDir: 'decks', + slideId: 'launch-deck', + commentId: 'c-deadbeef', + note: 'Tighten this.', + }); + + expect(prompt).toContain('decks/launch-deck/index.tsx'); + }); +}); + +describe('extractText', () => { + it('normalizes string, array, object, and empty content', () => { + expect(extractText('hello')).toBe('hello'); + expect(extractText([{ text: 'hello' }, { text: 'world' }, { nope: true }])).toBe('hello world'); + expect(extractText({ ok: true })).toBe('{"ok":true}'); + expect(extractText(null)).toBe(''); + }); +}); diff --git a/packages/core/src/vite/superconnector-plugin.ts b/packages/core/src/vite/superconnector-plugin.ts new file mode 100644 index 00000000..8685397f --- /dev/null +++ b/packages/core/src/vite/superconnector-plugin.ts @@ -0,0 +1,245 @@ +import { randomUUID } from 'node:crypto'; +import type { ServerResponse } from 'node:http'; +import type { AgentMessageType, Superconnector } from '@nimrobo/superconnector'; +import type { Connect, Plugin, ViteDevServer } from 'vite'; + +const MAX_MESSAGES = 50; +const MAX_MESSAGE_TEXT = 300; +const SLIDE_ID_RE = /^[a-z0-9_-]+$/i; + +export type AgentRunStatus = 'pending' | 'done' | 'error' | 'canceled'; + +export type AgentRunMessage = { + type: AgentMessageType; + text: string; + ts: string; +}; + +type AgentRun = { + status: AgentRunStatus; + runId: string; + sessionId?: string; + error?: string; + slideId: string; + commentId: string; + controller: AbortController; + messages: AgentRunMessage[]; +}; + +type RunBody = { + slideId?: string; + commentId?: string; + line?: number; + note?: string; +}; + +function json(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify(body)); +} + +async function readBody(req: Connect.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on('data', (c: Buffer) => chunks.push(c)); + req.on('end', () => { + const raw = Buffer.concat(chunks).toString('utf8'); + if (!raw) return resolve({}); + try { + resolve(JSON.parse(raw)); + } catch (e) { + reject(e); + } + }); + req.on('error', reject); + }); +} + +function newRunId(): string { + return `scr-${randomUUID().replace(/-/g, '').slice(0, 12)}`; +} + +export function superconnectorAppId(): string { + return 'open-slide'; +} + +export function superconnectorSessionSelector(slideId: string): string { + return `slide:${slideId}`; +} + +export function buildSuperconnectorPrompt(args: { + slidesDir: string; + slideId: string; + commentId: string; + line?: number; + note: string; +}): string { + const slidePath = `${args.slidesDir}/${args.slideId}/index.tsx`; + return [ + 'You are working in the slide project.', + `The slide file is at ${slidePath}.`, + `A comment was added${args.line ? ` at line ${args.line}` : ''} with this instruction:`, + `"${args.note}"`, + 'Please implement this change in the slide file.', + `When done, remove the @slide-comment marker with id="${args.commentId}" from the file.`, + ].join('\n'); +} + +export function extractText(content: unknown): string { + if (typeof content === 'string') return content; + if (Array.isArray(content)) { + return content + .filter((c): c is { text: string } => !!c && typeof c === 'object' && 'text' in c) + .map((c) => c.text) + .join(' '); + } + if (content && typeof content === 'object') return JSON.stringify(content); + return String(content ?? ''); +} + +export type SuperconnectorPluginOptions = { + userCwd: string; + slidesDir?: string; +}; + +export function superconnectorPlugin(opts: SuperconnectorPluginOptions): Plugin { + const slidesDir = opts.slidesDir ?? 'slides'; + + return { + name: 'open-slide:superconnector', + apply: 'serve', + async configureServer(server: ViteDevServer) { + let sc: Superconnector | null = null; + + try { + const mod = await import('@nimrobo/superconnector'); + sc = mod.createSuperconnector({ cwd: opts.userCwd, adapter: 'claude-code' }); + console.log('[superconnector] ready'); + } catch { + return; + } + + const runs = new Map(); + + const push = (run: AgentRun, msgType: AgentMessageType | AgentRunStatus, text: string) => { + server.ws.send({ + type: 'custom', + event: 'open-slide:superconnector-event', + data: { + runId: run.runId, + sessionId: run.sessionId, + commentId: run.commentId, + status: run.status, + msgType, + text, + }, + }); + }; + + server.middlewares.use('/__superconnector', async (req, res, next) => { + const url = new URL(req.url ?? '/', 'http://local'); + const method = req.method ?? 'GET'; + + try { + if (method === 'GET' && url.pathname === '/available') { + return json(res, 200, { available: true }); + } + + if (method === 'POST' && url.pathname === '/runs') { + const body = (await readBody(req)) as RunBody; + const { slideId, commentId, line, note } = body; + if (!slideId || !commentId || !note) { + return json(res, 400, { error: 'missing slideId, commentId, or note' }); + } + if (!SLIDE_ID_RE.test(slideId)) return json(res, 400, { error: 'invalid slideId' }); + + const runId = newRunId(); + const run: AgentRun = { + status: 'pending', + runId, + slideId, + commentId, + controller: new AbortController(), + messages: [], + }; + runs.set(runId, run); + + console.log(`[superconnector] spawn comment=${commentId} slide=${slideId}`); + + const agent = sc; + setImmediate(async () => { + console.log(`[superconnector] ▶ ${runId}`); + try { + for await (const msg of agent.spawn({ + prompt: buildSuperconnectorPrompt({ slidesDir, slideId, commentId, line, note }), + appId: superconnectorAppId(), + sessionSelector: superconnectorSessionSelector(slideId), + resumeLastCreatedSession: true, + permissionMode: 'acceptEdits', + signal: run.controller.signal, + })) { + if (msg.sessionId) run.sessionId = msg.sessionId; + const text = extractText(msg.content).slice(0, MAX_MESSAGE_TEXT); + const msgType = msg.type; + console.log(`[superconnector] ${msgType.padEnd(12)} ${text.slice(0, 120)}`); + + run.messages.push({ type: msgType, text, ts: new Date().toISOString() }); + if (run.messages.length > MAX_MESSAGES) run.messages.shift(); + + if (text) push(run, msgType, text); + } + + run.status = run.controller.signal.aborted ? 'canceled' : 'done'; + console.log(`[superconnector] ✓ ${runId} ${run.status}`); + push(run, run.status, ''); + } catch (err) { + if (run.controller.signal.aborted) { + run.status = 'canceled'; + console.log(`[superconnector] ■ ${runId} canceled`); + push(run, 'canceled', ''); + return; + } + const errText = String((err as Error).message ?? err); + console.error(`[superconnector] ✗ ${runId}`, errText); + run.status = 'error'; + run.error = errText; + push(run, 'error', errText); + } + }); + + return json(res, 200, { runId }); + } + + if (method === 'GET' && url.pathname.startsWith('/runs/')) { + const runId = url.pathname.slice('/runs/'.length); + const run = runs.get(runId); + if (!run) return json(res, 404, { error: 'unknown run' }); + return json(res, 200, { + status: run.status, + sessionId: run.sessionId, + error: run.error, + messages: run.messages, + }); + } + + if ( + method === 'POST' && + url.pathname.startsWith('/runs/') && + url.pathname.endsWith('/cancel') + ) { + const runId = url.pathname.slice('/runs/'.length, -'/cancel'.length); + const run = runs.get(runId); + if (!run) return json(res, 404, { error: 'unknown run' }); + if (run.status === 'pending') run.controller.abort(); + return json(res, 200, { ok: true }); + } + + next(); + } catch (err) { + json(res, 500, { error: String((err as Error).message ?? err) }); + } + }); + }, + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac798898..68b964f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@fontsource-variable/geist': specifier: ^5.2.8 version: 5.2.8 + '@nimrobo/superconnector': + specifier: ^0.1.0 + version: 0.1.0 '@tailwindcss/vite': specifier: ^4.2.2 version: 4.2.2(vite@5.4.21(@types/node@22.19.17)(lightningcss@1.32.0)) @@ -1197,6 +1200,10 @@ packages: cpu: [x64] os: [win32] + '@nimrobo/superconnector@0.1.0': + resolution: {integrity: sha512-mVTNPW/P+hnZCJAOaL1Bwg/USPeMS6ZEbUDZoUWJad0nT/nA+DkXXlYZOt0YuQrTBrzAP4XCL6M+q2gov6zWjg==} + engines: {node: '>=20'} + '@noble/ciphers@1.3.0': resolution: {integrity: sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==} engines: {node: ^14.21.3 || >=16} @@ -6217,6 +6224,8 @@ snapshots: '@next/swc-win32-x64-msvc@16.2.4': optional: true + '@nimrobo/superconnector@0.1.0': {} + '@noble/ciphers@1.3.0': {} '@noble/curves@1.9.7': From 90c4aaf8b0ad694b1236ad650744e5ef87319d14 Mon Sep 17 00:00:00 2001 From: Virang Jhaveri Date: Tue, 12 May 2026 01:03:58 +0530 Subject: [PATCH 2/2] Fix superconnector run lifecycle --- .changeset/superconnector-integration.md | 2 +- .../inspector/comment-superconnect.tsx | 107 ++++++++++++++---- .../src/vite/superconnector-plugin.test.ts | 51 +++++++++ .../core/src/vite/superconnector-plugin.ts | 89 +++++++++++++-- 4 files changed, 215 insertions(+), 34 deletions(-) diff --git a/.changeset/superconnector-integration.md b/.changeset/superconnector-integration.md index 6215afec..0ca5b321 100644 --- a/.changeset/superconnector-integration.md +++ b/.changeset/superconnector-integration.md @@ -2,4 +2,4 @@ '@open-slide/core': minor --- -Add a "Run with agent" action to inspector comments. Each comment is dispatched through `@nimrobo/superconnector` to a connected coding agent, with run status streamed back to the UI over HMR. +Adds a "Run with agent" action to inspector comments. diff --git a/packages/core/src/app/components/inspector/comment-superconnect.tsx b/packages/core/src/app/components/inspector/comment-superconnect.tsx index 2fedec72..a8f462a8 100644 --- a/packages/core/src/app/components/inspector/comment-superconnect.tsx +++ b/packages/core/src/app/components/inspector/comment-superconnect.tsx @@ -3,29 +3,48 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { toast } from 'sonner'; import { useLocale } from '@/lib/use-locale'; -export type AgentStatus = 'idle' | 'pending' | 'done' | 'error' | 'canceled'; +export type AgentStatus = 'idle' | 'pending' | 'canceling' | 'done' | 'error' | 'canceled'; -type ActiveStatus = Exclude; +type RunStatusValue = Exclude; type SuperconnectorEvent = { runId: string; sessionId?: string; commentId: string; - status: ActiveStatus; + status: RunStatusValue; msgType: string; text: string; }; type RunStatus = { - status: ActiveStatus; + status: RunStatusValue; sessionId?: string; error?: string; messages?: Array<{ text: string }>; }; +const FETCH_TIMEOUT_MS = 10_000; + +async function fetchWithTimeout( + input: RequestInfo | URL, + init: RequestInit | undefined, + label: string, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + try { + return await fetch(input, { ...init, signal: controller.signal }); + } catch (err) { + if (controller.signal.aborted) throw new Error(`${label} timed out`); + throw err; + } finally { + clearTimeout(timer); + } +} + async function checkAgentAvailable(): Promise { try { - const res = await fetch('/__superconnector/available'); + const res = await fetchWithTimeout('/__superconnector/available', undefined, 'agent check'); if (!res.ok) return false; const data = (await res.json()) as { available?: boolean }; return data.available === true; @@ -40,11 +59,15 @@ async function startAgentRun( line: number, note: string, ): Promise { - const res = await fetch('/__superconnector/runs', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ slideId, commentId, line, note }), - }); + const res = await fetchWithTimeout( + '/__superconnector/runs', + { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ slideId, commentId, line, note }), + }, + 'agent run', + ); if (!res.ok) { const body = (await res.json().catch(() => ({}))) as { error?: string }; throw new Error(body.error ?? `agent run failed: ${res.status}`); @@ -54,13 +77,17 @@ async function startAgentRun( } async function pollStatus(runId: string): Promise { - const res = await fetch(`/__superconnector/runs/${runId}`); + const res = await fetchWithTimeout(`/__superconnector/runs/${runId}`, undefined, 'agent status'); if (!res.ok) throw new Error(`status poll failed: ${res.status}`); return (await res.json()) as RunStatus; } async function cancelAgentRun(runId: string): Promise { - const res = await fetch(`/__superconnector/runs/${runId}/cancel`, { method: 'POST' }); + const res = await fetchWithTimeout( + `/__superconnector/runs/${runId}/cancel`, + { method: 'POST' }, + 'agent cancel', + ); if (!res.ok) throw new Error(`cancel failed: ${res.status}`); } @@ -78,7 +105,8 @@ export function useAgentRuns(slideId: string, onDone: () => void | Promise const [status, setStatusMap] = useState>(new Map()); const [log, setLogMap] = useState>(new Map()); const [runs, setRunsMap] = useState>(new Map()); - const pollTimers = useRef>>(new Map()); + const pollTimers = useRef>>(new Map()); + const previousSlideId = useRef(slideId); useEffect(() => { checkAgentAvailable().then(setAvailable); @@ -86,7 +114,8 @@ export function useAgentRuns(slideId: string, onDone: () => void | Promise useEffect(() => { return () => { - for (const timer of pollTimers.current.values()) clearInterval(timer); + for (const timer of pollTimers.current.values()) clearTimeout(timer); + pollTimers.current.clear(); }; }, []); @@ -97,13 +126,23 @@ export function useAgentRuns(slideId: string, onDone: () => void | Promise const stopPolling = useCallback((id: string) => { const timer = pollTimers.current.get(id); if (timer !== undefined) { - clearInterval(timer); + clearTimeout(timer); pollTimers.current.delete(id); } }, []); + useEffect(() => { + if (previousSlideId.current === slideId) return; + previousSlideId.current = slideId; + for (const timer of pollTimers.current.values()) clearTimeout(timer); + pollTimers.current.clear(); + setStatusMap(new Map()); + setLogMap(new Map()); + setRunsMap(new Map()); + }, [slideId]); + const finishRun = useCallback( - (commentId: string, s: ActiveStatus, text?: string) => { + (commentId: string, s: RunStatusValue, text?: string) => { stopPolling(commentId); setStatus(commentId, s); if (text) setLogMap((prev) => new Map(prev).set(commentId, text)); @@ -131,22 +170,29 @@ export function useAgentRuns(slideId: string, onDone: () => void | Promise const handler = (data: SuperconnectorEvent) => { const { commentId, msgType, status: s, text } = data; if (msgType === 'done') { + if (!pollTimers.current.has(commentId)) return; + stopPolling(commentId); finishRun(commentId, 'done'); } else if (msgType === 'error') { + if (!pollTimers.current.has(commentId)) return; + stopPolling(commentId); finishRun(commentId, 'error', text); } else if (msgType === 'canceled') { + if (!pollTimers.current.has(commentId)) return; + stopPolling(commentId); finishRun(commentId, 'canceled'); - } else if (text) { + } else if (pollTimers.current.has(commentId) && text) { setLogMap((prev) => new Map(prev).set(commentId, text)); setStatus(commentId, s); } }; import.meta.hot.on('open-slide:superconnector-event', handler); return () => import.meta.hot?.off('open-slide:superconnector-event', handler); - }, [finishRun, setStatus]); + }, [finishRun, setStatus, stopPolling]); const run = useCallback( async (commentId: string, line: number, note: string) => { + stopPolling(commentId); setStatus(commentId, 'pending'); toast(t.inspector.agentRunning, { icon: '▶' }); let runId: string; @@ -159,36 +205,48 @@ export function useAgentRuns(slideId: string, onDone: () => void | Promise return; } - const timer = setInterval(async () => { + const pollLoop = async () => { + if (!pollTimers.current.has(commentId)) return; try { const result = await pollStatus(runId); + if (!pollTimers.current.has(commentId)) return; if (result.messages?.length) { const last = result.messages.at(-1); if (last?.text) setLogMap((prev) => new Map(prev).set(commentId, last.text)); } if (result.status !== 'pending') { + if (!pollTimers.current.has(commentId)) return; + stopPolling(commentId); finishRun(commentId, result.status, result.error); + return; } + const timer = setTimeout(pollLoop, 2000); + pollTimers.current.set(commentId, timer); } catch { + if (!pollTimers.current.has(commentId)) return; + stopPolling(commentId); finishRun(commentId, 'error'); } - }, 2000); + }; + const timer = setTimeout(pollLoop, 2000); pollTimers.current.set(commentId, timer); }, - [finishRun, setStatus, slideId, t.inspector.agentError, t.inspector.agentRunning], + [finishRun, setStatus, slideId, stopPolling, t.inspector.agentError, t.inspector.agentRunning], ); const cancel = useCallback( async (commentId: string) => { const runId = runs.get(commentId); if (!runId) return; + setStatus(commentId, 'canceling'); try { await cancelAgentRun(runId); } catch { + setStatus(commentId, 'pending'); toast.error(t.inspector.agentError); } }, - [runs, t.inspector.agentError], + [runs, setStatus, t.inspector.agentError], ); return { @@ -223,11 +281,12 @@ export function AgentRunButton({ if (!runs.available) return null; const status = runs.statusOf(commentId); - if (status === 'pending') { + if (status === 'pending' || status === 'canceling') { return ( ); } diff --git a/packages/core/src/vite/superconnector-plugin.test.ts b/packages/core/src/vite/superconnector-plugin.test.ts index 8b42d9de..7043d62f 100644 --- a/packages/core/src/vite/superconnector-plugin.test.ts +++ b/packages/core/src/vite/superconnector-plugin.test.ts @@ -2,6 +2,9 @@ import { describe, expect, it } from 'vitest'; import { buildSuperconnectorPrompt, extractText, + isAllowedSuperconnectorMutation, + isMalformedJsonError, + pruneCompletedRuns, superconnectorAppId, superconnectorSessionSelector, } from './superconnector-plugin.ts'; @@ -49,3 +52,51 @@ describe('extractText', () => { expect(extractText(null)).toBe(''); }); }); + +describe('isMalformedJsonError', () => { + it('identifies JSON parse failures', () => { + expect(isMalformedJsonError(new SyntaxError('bad json'))).toBe(true); + expect(isMalformedJsonError(new Error('socket closed'))).toBe(false); + }); +}); + +describe('isAllowedSuperconnectorMutation', () => { + it('allows same-origin and originless dev requests', () => { + expect(isAllowedSuperconnectorMutation({ headers: { host: 'localhost:5173' } } as never)).toBe( + true, + ); + expect( + isAllowedSuperconnectorMutation({ + headers: { host: 'localhost:5173', origin: 'http://localhost:5173' }, + } as never), + ).toBe(true); + }); + + it('rejects cross-origin and malformed origins', () => { + expect( + isAllowedSuperconnectorMutation({ + headers: { host: 'localhost:5173', origin: 'http://evil.test' }, + } as never), + ).toBe(false); + expect( + isAllowedSuperconnectorMutation({ + headers: { host: 'localhost:5173', origin: '%%%bad' }, + } as never), + ).toBe(false); + }); +}); + +describe('pruneCompletedRuns', () => { + it('removes oldest completed runs without deleting pending runs', () => { + const timeout = setTimeout(() => {}, 1000); + clearTimeout(timeout); + const runs = new Map([ + ['pending', { status: 'pending' as const }], + ['old', { status: 'done' as const, completedAt: 1, cleanupTimer: timeout }], + ['new', { status: 'error' as const, completedAt: 2 }], + ]); + + expect(pruneCompletedRuns(runs, 2)).toEqual(['old']); + expect([...runs.keys()]).toEqual(['pending', 'new']); + }); +}); diff --git a/packages/core/src/vite/superconnector-plugin.ts b/packages/core/src/vite/superconnector-plugin.ts index 8685397f..72080ee2 100644 --- a/packages/core/src/vite/superconnector-plugin.ts +++ b/packages/core/src/vite/superconnector-plugin.ts @@ -6,6 +6,8 @@ import type { Connect, Plugin, ViteDevServer } from 'vite'; const MAX_MESSAGES = 50; const MAX_MESSAGE_TEXT = 300; const SLIDE_ID_RE = /^[a-z0-9_-]+$/i; +const COMPLETED_RUN_TTL_MS = 10 * 60 * 1000; +const MAX_RETAINED_RUNS = 100; export type AgentRunStatus = 'pending' | 'done' | 'error' | 'canceled'; @@ -24,6 +26,8 @@ type AgentRun = { commentId: string; controller: AbortController; messages: AgentRunMessage[]; + completedAt?: number; + cleanupTimer?: ReturnType; }; type RunBody = { @@ -56,6 +60,44 @@ async function readBody(req: Connect.IncomingMessage): Promise { }); } +export function isMalformedJsonError(err: unknown): boolean { + return err instanceof SyntaxError; +} + +export function isAllowedSuperconnectorMutation(req: Connect.IncomingMessage): boolean { + const host = req.headers.host; + const origin = req.headers.origin; + if (!origin) return true; + if (!host) return false; + + try { + return new URL(origin).host === host; + } catch { + return false; + } +} + +export function pruneCompletedRuns( + runs: Map>, + maxEntries = MAX_RETAINED_RUNS, +): string[] { + if (runs.size <= maxEntries) return []; + + const completed = [...runs.entries()] + .filter(([, run]) => run.status !== 'pending') + .sort(([, a], [, b]) => (a.completedAt ?? 0) - (b.completedAt ?? 0)); + const deleted: string[] = []; + + for (const [runId, run] of completed) { + if (runs.size <= maxEntries) break; + if (run.cleanupTimer) clearTimeout(run.cleanupTimer); + runs.delete(runId); + deleted.push(runId); + } + + return deleted; +} + function newRunId(): string { return `scr-${randomUUID().replace(/-/g, '').slice(0, 12)}`; } @@ -137,6 +179,23 @@ export function superconnectorPlugin(opts: SuperconnectorPluginOptions): Plugin }); }; + const finalizeRun = ( + run: AgentRun, + status: Exclude, + text = '', + ) => { + if (run.status !== 'pending') return; + run.status = status; + run.completedAt = Date.now(); + if (status === 'error') run.error = text; + push(run, status, text); + run.cleanupTimer = setTimeout(() => { + const current = runs.get(run.runId); + if (current && current.status !== 'pending') runs.delete(run.runId); + }, COMPLETED_RUN_TTL_MS); + pruneCompletedRuns(runs, MAX_RETAINED_RUNS); + }; + server.middlewares.use('/__superconnector', async (req, res, next) => { const url = new URL(req.url ?? '/', 'http://local'); const method = req.method ?? 'GET'; @@ -147,7 +206,18 @@ export function superconnectorPlugin(opts: SuperconnectorPluginOptions): Plugin } if (method === 'POST' && url.pathname === '/runs') { - const body = (await readBody(req)) as RunBody; + if (!isAllowedSuperconnectorMutation(req)) { + return json(res, 403, { error: 'forbidden' }); + } + + let body: RunBody; + try { + body = (await readBody(req)) as RunBody; + } catch (err) { + if (isMalformedJsonError(err)) return json(res, 400, { error: 'malformed JSON' }); + throw err; + } + const { slideId, commentId, line, note } = body; if (!slideId || !commentId || !note) { return json(res, 400, { error: 'missing slideId, commentId, or note' }); @@ -190,21 +260,18 @@ export function superconnectorPlugin(opts: SuperconnectorPluginOptions): Plugin if (text) push(run, msgType, text); } - run.status = run.controller.signal.aborted ? 'canceled' : 'done'; - console.log(`[superconnector] ✓ ${runId} ${run.status}`); - push(run, run.status, ''); + const status = run.controller.signal.aborted ? 'canceled' : 'done'; + console.log(`[superconnector] ✓ ${runId} ${status}`); + finalizeRun(run, status); } catch (err) { if (run.controller.signal.aborted) { - run.status = 'canceled'; console.log(`[superconnector] ■ ${runId} canceled`); - push(run, 'canceled', ''); + finalizeRun(run, 'canceled'); return; } const errText = String((err as Error).message ?? err); console.error(`[superconnector] ✗ ${runId}`, errText); - run.status = 'error'; - run.error = errText; - push(run, 'error', errText); + finalizeRun(run, 'error', errText); } }); @@ -228,6 +295,10 @@ export function superconnectorPlugin(opts: SuperconnectorPluginOptions): Plugin url.pathname.startsWith('/runs/') && url.pathname.endsWith('/cancel') ) { + if (!isAllowedSuperconnectorMutation(req)) { + return json(res, 403, { error: 'forbidden' }); + } + const runId = url.pathname.slice('/runs/'.length, -'/cancel'.length); const run = runs.get(runId); if (!run) return json(res, 404, { error: 'unknown run' });