Skip to content

Built for people who want to own their automations. Join the waitlist for an invite.

Package listing

@kentcdodds/cursor

src/runs.ts

276 lines · 7.2 KB · TypeScript
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.
 *
 * @param params.agentId - Target agent id.
 * @param params.prompt - Follow-up instruction text (or `{ text, images? }`).
 * @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<CreateRunResult> {
	const agentId = requireAgentId(params.agentId)
	const body: Record<string, unknown> = {
		prompt: normalizePrompt(params.prompt),
	}
	if (params.mcpServers) body.mcpServers = params.mcpServers
	if (params.mode) body.mode = params.mode

	return cursorApi<CreateRunResult>({
		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<ListRunsResult> {
	const agentId = requireAgentId(params.agentId)
	const query: Record<string, string | number> = {}
	if (params.limit !== undefined) {
		query.limit = clampInt(params.limit, 1, 100, 20)
	}
	if (params.cursor) query.cursor = params.cursor

	return cursorApi<ListRunsResult>({
		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<RunSummary> {
	const agentId = requireAgentId(params.agentId)
	const runId = trimString(params.runId)
	if (!runId) throw new Error('runId is required')
	return cursorApi<RunSummary>({
		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<string, string> = {}
	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<string, string> = {}
	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