import { clampInt, cursorApi, cursorRequestRaw, trimString } from './lib/client.ts' import type { PromptInput } from './agents.ts' export type CreateRunParams = { agentId: string prompt: PromptInput | string mcpServers?: unknown[] mode?: 'agent' | 'plan' } export type ListRunsParams = { agentId: string limit?: number cursor?: string } export type RunSummary = { id: string agentId?: string status?: string createdAt?: string updatedAt?: string durationMs?: number result?: string git?: { branches?: Array<{ repoUrl?: string branch?: string prUrl?: string }> } [key: string]: unknown } export type ListRunsResult = { items: RunSummary[] nextCursor?: string [key: string]: unknown } export type CreateRunResult = { run: RunSummary [key: string]: unknown } function normalizePrompt( prompt: PromptInput | string | undefined, ): PromptInput { if (typeof prompt === 'string') return { text: trimString(prompt) } if (prompt && typeof prompt === 'object' && trimString(prompt.text)) { return prompt } throw new Error('prompt.text is required') } function requireAgentId(agentId: string | undefined): string { const id = trimString(agentId) if (!id) throw new Error('agentId is required') return id } /** * Send a follow-up prompt to an existing active agent. * * For binary/file handoffs, mint `downloadUrl` via * `kody:@kentcdodds/agent-files/create-upload`, PUT the file, then include * that URL in the prompt text — do not depend on `prompt.images` for bytes * (`images` is vision-only). * * @param params.agentId - Target agent id. * @param params.prompt - Follow-up instruction text (or `{ text, images? }`). Prefer agent-files `downloadUrl`s in `text`. `images` is vision-only. * @returns The newly created run. * @example * import { createRun } from 'kody:@kentcdodds/cursor/runs' * const { run } = await createRun({ * agentId: 'bc-00000000-0000-0000-0000-000000000001', * prompt: 'Also add troubleshooting steps', * }) * // => { run: { id: 'run-...', status: 'CREATING' } } */ export async function createRun( params: CreateRunParams, ): Promise { const agentId = requireAgentId(params.agentId) const body: Record = { prompt: normalizePrompt(params.prompt), } if (params.mcpServers) body.mcpServers = params.mcpServers if (params.mode) body.mode = params.mode return cursorApi({ method: 'POST', path: `/v1/agents/${agentId}/runs`, body, }) } /** * List runs for an agent, newest first. * * @param params.agentId - Agent id. * @param params.limit - Page size (1–100); default 20. * @returns Run summaries and optional `nextCursor`. * @example * import { listRuns } from 'kody:@kentcdodds/cursor/runs' * const { items } = await listRuns({ agentId: 'bc-...', limit: 10 }) * // => { items: [{ id: 'run-...', status: 'FINISHED' }] } */ export async function listRuns( params: ListRunsParams, ): Promise { const agentId = requireAgentId(params.agentId) const query: Record = {} if (params.limit !== undefined) { query.limit = clampInt(params.limit, 1, 100, 20) } if (params.cursor) query.cursor = params.cursor return cursorApi({ path: `/v1/agents/${agentId}/runs`, query, }) } /** * Retrieve status and result for a specific run. * * @param params.agentId - Agent id. * @param params.runId - Run id. * @returns Run details including terminal result and git branches. * @example * import { getRun } from 'kody:@kentcdodds/cursor/runs' * const run = await getRun({ agentId: 'bc-...', runId: 'run-...' }) * // => { id: 'run-...', status: 'FINISHED', result: '...' } */ export async function getRun(params: { agentId: string runId: string }): Promise { const agentId = requireAgentId(params.agentId) const runId = trimString(params.runId) if (!runId) throw new Error('runId is required') return cursorApi({ path: `/v1/agents/${agentId}/runs/${runId}`, }) } /** * Cancel the active run for an agent. * * @param params.agentId - Agent id. * @param params.runId - Run id to cancel. * @returns Confirmation with run id. * @example * import { cancelRun } from 'kody:@kentcdodds/cursor/runs' * await cancelRun({ agentId: 'bc-...', runId: 'run-...' }) * // => { id: 'run-...' } */ export async function cancelRun(params: { agentId: string; runId: string }) { const agentId = requireAgentId(params.agentId) const runId = trimString(params.runId) if (!runId) throw new Error('runId is required') return cursorApi({ method: 'POST', path: `/v1/agents/${agentId}/runs/${runId}/cancel`, }) } export type GetAgentUsageParams = { agentId: string runId?: string } /** * Retrieve token usage for an agent, optionally scoped to one run. * * @param params.agentId - Agent id. * @param params.runId - Optional run id to scope usage. * @returns Total and per-run token usage. * @example * import { getAgentUsage } from 'kody:@kentcdodds/cursor/runs' * const usage = await getAgentUsage({ agentId: 'bc-...' }) * // => { totalUsage: { totalTokens: 76390 }, runs: [...] } */ export async function getAgentUsage(params: GetAgentUsageParams) { const agentId = requireAgentId(params.agentId) const query: Record = {} if (params.runId) query.runId = params.runId return cursorApi({ path: `/v1/agents/${agentId}/usage`, query, }) } export type ReadRunStreamParams = { agentId: string runId: string parseEvents?: boolean lastEventId?: string } export type StreamEvent = { event?: string id?: string data: unknown } /** * Buffer-read the SSE stream for a run (full body, not incremental). * * @param params.agentId - Agent id. * @param params.runId - Run id. * @param params.parseEvents - When true, parse SSE into event objects. * @returns Raw stream text or parsed events. Buffers the entire response. * @example * import { readRunStream } from 'kody:@kentcdodds/cursor/runs' * const stream = await readRunStream({ * agentId: 'bc-...', * runId: 'run-...', * parseEvents: true, * }) * // => { events: [{ event: 'assistant', data: { text: '...' } }] } */ export async function readRunStream(params: ReadRunStreamParams) { const agentId = requireAgentId(params.agentId) const runId = trimString(params.runId) if (!runId) throw new Error('runId is required') const headers: Record = {} if (params.lastEventId) headers['Last-Event-ID'] = params.lastEventId const result = await cursorRequestRaw({ path: `/v1/agents/${agentId}/runs/${runId}/stream`, accept: 'text/event-stream', headers, }) const text = typeof result.body === 'string' ? result.body : '' const retention = result.headers['x-cursor-stream-retention-seconds'] || null if (!params.parseEvents) { return { status: result.status, text, retentionSeconds: retention, } } const events: StreamEvent[] = [] const blocks = text.split(/\n\n+/) for (const block of blocks) { if (!block.trim()) continue const lines = block.split('\n') let event: string | undefined let id: string | undefined let dataLine = '' for (const line of lines) { if (line.startsWith('event:')) event = line.slice(6).trim() else if (line.startsWith('id:')) id = line.slice(3).trim() else if (line.startsWith('data:')) dataLine += line.slice(5).trim() } let data: unknown = dataLine if (dataLine) { try { data = JSON.parse(dataLine) } catch { data = dataLine } } events.push({ event, id, data }) } return { status: result.status, events, retentionSeconds: retention, } } /** Default export: list runs for an agent. */ export default listRuns