From a8f7e0ba58d9711e16b6d23fccf9d4fd2cd6e949 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 04:27:47 +0000 Subject: [PATCH 1/3] cli: share the PASCAL_API_KEY requirement from agent-account --- packages/cli/src/agent-account.ts | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/agent-account.ts b/packages/cli/src/agent-account.ts index fbf44f603a..2d406b39f6 100644 --- a/packages/cli/src/agent-account.ts +++ b/packages/cli/src/agent-account.ts @@ -33,17 +33,22 @@ interface AgentAccountRequestOptions { timeoutMs?: number } -export async function startAgentClaim( - apiKey: string, - options: AgentAccountRequestOptions = {}, -): Promise { - const credential = apiKey.trim() +export function requireAgentApiKey(apiKey: string | undefined, holder = "this agent's"): string { + const credential = apiKey?.trim() if (!credential) { throw new CliError( 'agent_api_key_missing', - "Set PASCAL_API_KEY to this autonomous agent's API key and try again.", + `Set PASCAL_API_KEY to ${holder} API key and try again.`, ) } + return credential +} + +export async function startAgentClaim( + apiKey: string, + options: AgentAccountRequestOptions = {}, +): Promise { + const credential = requireAgentApiKey(apiKey, "this autonomous agent's") const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS) @@ -89,13 +94,7 @@ export async function getAgentStatus( apiKey: string, options: AgentAccountRequestOptions = {}, ): Promise { - const credential = apiKey.trim() - if (!credential) { - throw new CliError( - 'agent_api_key_missing', - "Set PASCAL_API_KEY to this agent's API key and try again.", - ) - } + const credential = requireAgentApiKey(apiKey) const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS) From a58e388fd58f25d187714fcbfe8cf98b3bbde310 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 04:27:48 +0000 Subject: [PATCH 2/3] cli: add pascal architect run/status/cancel over the hosted Responses API --- packages/cli/src/architect.test.ts | 342 ++++++++++++++++++ packages/cli/src/architect.ts | 553 +++++++++++++++++++++++++++++ packages/cli/src/bin/pascal.ts | 180 +++++++++- packages/cli/src/cli.test.ts | 212 +++++++++++ 4 files changed, 1286 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/architect.test.ts create mode 100644 packages/cli/src/architect.ts diff --git a/packages/cli/src/architect.test.ts b/packages/cli/src/architect.test.ts new file mode 100644 index 0000000000..75fb08d75a --- /dev/null +++ b/packages/cli/src/architect.test.ts @@ -0,0 +1,342 @@ +import { describe, expect, test } from 'bun:test' +import { + ARCHITECT_RESPONSES_ENDPOINT, + cancelArchitectRun, + getArchitectRun, + startArchitectRun, + streamArchitectRun, +} from './architect.js' +import { CliError } from './errors.js' + +const API_KEY = 'sk_live_architect-test-key' + +const QUEUED = { + id: 'resp_architect1', + object: 'response', + status: 'queued', + model: 'architect-high', + created_at: 1_790_000_000, + metadata: { project_id: 'proj_1', pascal_max_credits: '40' }, + output: [], +} + +const COMPLETED = { + ...QUEUED, + status: 'completed', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'Two-storey house\u001b[2J placed.' }], + }, + ], +} + +interface Captured { + url: string + method?: string + authorization: string | null + accept: string | null + redirect?: RequestRedirect + body: unknown +} + +function captureFetch(respond: () => Response, calls: Captured[] = []): typeof fetch { + return async (input, init) => { + const headers = new Headers(init?.headers) + calls.push({ + url: String(input), + method: init?.method, + authorization: headers.get('authorization'), + accept: headers.get('accept'), + redirect: init?.redirect, + body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined, + }) + return respond() + } +} + +function sse(events: unknown[], { close = true } = {}): Response { + const encoder = new TextEncoder() + return new Response( + new ReadableStream({ + start(controller) { + for (const event of events) { + const type = (event as { type: string }).type + controller.enqueue(encoder.encode(`event: ${type}\ndata: ${JSON.stringify(event)}\n\n`)) + } + if (close) controller.close() + }, + }), + { headers: { 'content-type': 'text/event-stream' } }, + ) +} + +describe('architect runs', () => { + test('queues a background run with the effort model and Pascal metadata', async () => { + const calls: Captured[] = [] + const run = await startArchitectRun( + API_KEY, + { brief: ' A three-bedroom house ', effort: 'high', projectId: 'proj_1', maxCredits: 40 }, + { fetch: captureFetch(() => Response.json(QUEUED), calls) }, + ) + + expect(calls).toEqual([ + { + url: ARCHITECT_RESPONSES_ENDPOINT, + method: 'POST', + authorization: `Bearer ${API_KEY}`, + accept: 'application/json', + redirect: 'error', + body: { + model: 'architect-high', + input: 'A three-bedroom house', + background: true, + store: true, + stream: false, + metadata: { project_id: 'proj_1', pascal_max_credits: '40' }, + }, + }, + ]) + expect(run).toEqual({ + schemaVersion: 1, + id: 'resp_architect1', + status: 'queued', + model: 'architect-high', + projectId: 'proj_1', + maxCredits: 40, + createdAt: new Date(1_790_000_000 * 1000).toISOString(), + outputText: null, + error: null, + }) + }) + + test('uses the default architect model without metadata when no options are set', async () => { + const calls: Captured[] = [] + await startArchitectRun( + API_KEY, + { brief: 'A cabin' }, + { fetch: captureFetch(() => Response.json(QUEUED), calls) }, + ) + + expect(calls[0]?.body).toEqual({ + model: 'architect', + input: 'A cabin', + background: true, + store: true, + stream: false, + }) + }) + + test('streams text deltas and returns the terminal run with terminal-safe output', async () => { + const calls: Captured[] = [] + const started: string[] = [] + const deltas: string[] = [] + const run = await streamArchitectRun( + API_KEY, + { brief: 'A cabin', effort: 'low' }, + { + fetch: captureFetch( + () => + sse([ + { type: 'response.created', response: QUEUED }, + { type: 'response.in_progress', response: { ...QUEUED, status: 'in_progress' } }, + { type: 'response.output_text.delta', delta: 'Two-storey ' }, + { type: 'response.output_text.delta', delta: 'house\u001b[2J placed.' }, + { type: 'response.completed', response: COMPLETED }, + ]), + calls, + ), + onStarted: (value) => started.push(value.id), + onText: (delta) => deltas.push(delta), + }, + ) + + expect(calls[0]?.accept).toBe('text/event-stream') + expect(calls[0]?.body).toMatchObject({ model: 'architect-low', background: true, stream: true }) + expect(started).toEqual(['resp_architect1']) + expect(deltas).toEqual(['Two-storey ', 'house[2J placed.']) + expect(run.status).toBe('completed') + expect(run.outputText).toBe('Two-storey house[2J placed.') + }) + + test('reports a dropped stream with the run ID so the caller can resume', async () => { + const error = await captureError(() => + streamArchitectRun( + API_KEY, + { brief: 'A cabin' }, + { fetch: async () => sse([{ type: 'response.created', response: QUEUED }]) }, + ), + ) + + expect(error.code).toBe('architect_stream_interrupted') + expect(error.details).toEqual({ id: 'resp_architect1' }) + expect(error.message).toContain('pascal architect status resp_architect1') + }) + + test('detaches without cancelling when the caller aborts mid-stream', async () => { + const detach = new AbortController() + const error = await captureError(() => + streamArchitectRun( + API_KEY, + { brief: 'A cabin' }, + { + fetch: async () => + sse([{ type: 'response.created', response: QUEUED }], { close: false }), + signal: detach.signal, + onStarted: () => queueMicrotask(() => detach.abort()), + }, + ), + ) + + expect(error.code).toBe('architect_detached') + expect(error.exitCode).toBe(130) + expect(error.details).toEqual({ id: 'resp_architect1' }) + }) + + test('reads and cancels a run by ID', async () => { + const calls: Captured[] = [] + const fetchMock = captureFetch(() => Response.json(COMPLETED), calls) + + const status = await getArchitectRun(API_KEY, 'resp_architect1', { fetch: fetchMock }) + await cancelArchitectRun(API_KEY, 'resp_architect1', { fetch: fetchMock }) + + expect(calls.map(({ url, method }) => [method, url])).toEqual([ + ['GET', `${ARCHITECT_RESPONSES_ENDPOINT}/resp_architect1`], + ['POST', `${ARCHITECT_RESPONSES_ENDPOINT}/resp_architect1/cancel`], + ]) + expect(status.outputText).toBe('Two-storey house[2J placed.') + }) + + test('keeps a failed run error bounded and terminal-safe', async () => { + const run = await getArchitectRun(API_KEY, 'resp_architect1', { + fetch: async () => + Response.json({ + ...QUEUED, + status: 'failed', + error: { code: 'credit_limit_reached', message: `Limit\u001b[31m ${'x'.repeat(900)}` }, + }), + }) + + expect(run.error?.code).toBe('credit_limit_reached') + expect(run.error?.message?.startsWith('Limit[31m x')).toBe(true) + expect(run.error?.message?.length).toBe(500) + }) + + test('rejects run IDs that could change the request path', async () => { + let called = false + const error = await captureError(() => + getArchitectRun(API_KEY, '../admin', { + fetch: async () => { + called = true + return Response.json(COMPLETED) + }, + }), + ) + + expect(error.code).toBe('invalid_option') + expect(called).toBe(false) + }) + + test.each([ + [{ brief: ' ' }], + [{ brief: 'A cabin', maxCredits: 0 }], + [{ brief: 'A cabin', projectId: '' }], + [{ brief: 'A cabin', effort: 'max' as never }], + ])('rejects invalid run options before any request: %j', async (request) => { + const error = await captureError(() => + startArchitectRun(API_KEY, request, { + fetch: async () => { + throw new Error('fetch must not be called') + }, + }), + ) + + expect(error.code).toBe('invalid_option') + expect(error.exitCode).toBe(2) + }) + + test('requires the agent credential', async () => { + const error = await captureError(() => startArchitectRun(undefined, { brief: 'A cabin' })) + + expect(error.code).toBe('agent_api_key_missing') + }) + + test('maps executor_unavailable to a retryable error that says no run started', async () => { + const fetchMock: typeof fetch = async () => + Response.json( + { error: { type: 'server_error', code: 'executor_unavailable', message: 'busy' } }, + { status: 503 }, + ) + + const error = await captureError(() => + startArchitectRun(API_KEY, { brief: 'A cabin' }, { fetch: fetchMock }), + ) + + expect(error.code).toBe('architect_executor_unavailable') + expect(error.exitCode).toBe(75) + expect(error.message).toContain('the run was not started') + expect(error.details).toEqual({ status: 503, code: 'executor_unavailable' }) + }) + + test('maps an executor_unavailable stream error event the same way', async () => { + const error = await captureError(() => + streamArchitectRun( + API_KEY, + { brief: 'A cabin' }, + { + fetch: async () => + sse([{ type: 'error', code: 'executor_unavailable', message: 'no executor' }]), + }, + ), + ) + + expect(error.code).toBe('architect_executor_unavailable') + }) + + test.each([ + [401, 'architect_unauthorized', 1], + [402, 'architect_insufficient_credits', 1], + [403, 'architect_forbidden', 1], + [404, 'architect_run_not_found', 1], + [429, 'architect_rate_limited', 75], + [503, 'architect_unavailable', 75], + [500, 'architect_failed', 1], + ])('maps HTTP %i without exposing the key or response body', async (status, code, exitCode) => { + const fetchMock: typeof fetch = async () => + new Response(`credential ${API_KEY} rejected`, { + headers: { 'content-type': 'text/html' }, + status, + }) + + const error = await captureError(() => + getArchitectRun(API_KEY, 'resp_architect1', { fetch: fetchMock }), + ) + + expect(error.code).toBe(code) + expect(error.exitCode).toBe(exitCode) + expect(error.details).toEqual({ status }) + expect(JSON.stringify(error)).not.toContain(API_KEY) + expect(error.message).not.toContain(API_KEY) + }) + + test('rejects responses that are not an architect run', async () => { + const error = await captureError(() => + getArchitectRun(API_KEY, 'resp_architect1', { + fetch: async () => Response.json({ id: 'resp_architect1', status: 'sleeping' }), + }), + ) + + expect(error.code).toBe('architect_invalid_response') + }) +}) + +async function captureError(action: () => Promise): Promise { + try { + await action() + } catch (error) { + if (error instanceof CliError) return error + throw error + } + throw new Error('Expected a CliError') +} diff --git a/packages/cli/src/architect.ts b/packages/cli/src/architect.ts new file mode 100644 index 0000000000..cea43023d6 --- /dev/null +++ b/packages/cli/src/architect.ts @@ -0,0 +1,553 @@ +import { requireAgentApiKey } from './agent-account.js' +import { CliError } from './errors.js' + +export const ARCHITECT_RESPONSES_ENDPOINT = 'https://editor.pascal.app/api/v1/responses' +export const ARCHITECT_EFFORTS = ['low', 'medium', 'high', 'pro'] as const +export type ArchitectEffort = (typeof ARCHITECT_EFFORTS)[number] + +const RUN_STATUSES = [ + 'queued', + 'in_progress', + 'completed', + 'failed', + 'cancelled', + 'incomplete', +] as const +export type ArchitectRunStatus = (typeof RUN_STATUSES)[number] + +/** Exit code for failures worth retrying unchanged later (sysexits EX_TEMPFAIL). */ +export const ARCHITECT_RETRY_EXIT_CODE = 75 + +const RESPONSE_ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ +const ERROR_CODE_PATTERN = /^[A-Za-z0-9_.-]{1,64}$/ +const MAX_BRIEF_LENGTH = 100_000 +const MAX_METADATA_VALUE_LENGTH = 512 +const MAX_SERVER_MESSAGE_LENGTH = 500 +const MAX_JSON_BYTES = 4 * 1024 * 1024 +const MAX_ERROR_BYTES = 32 * 1024 +const DEFAULT_TIMEOUT_MS = 30_000 + +export interface ArchitectRunRequest { + brief: string + effort?: ArchitectEffort + projectId?: string + maxCredits?: number +} + +export interface ArchitectRun { + schemaVersion: 1 + id: string + status: ArchitectRunStatus + model: string | null + projectId: string | null + maxCredits: number | null + createdAt: string | null + outputText: string | null + error: { code: string | null; message: string | null } | null +} + +export interface ArchitectRequestOptions { + fetch?: typeof fetch + timeoutMs?: number + /** Aborting detaches from the request; a background run keeps going on Pascal. */ + signal?: AbortSignal +} + +export interface ArchitectStreamOptions extends ArchitectRequestOptions { + onStarted?: (run: ArchitectRun) => void + onText?: (delta: string) => void +} + +type ArchitectAction = 'run' | 'status' | 'cancel' + +export function architectModel(effort?: ArchitectEffort): string { + return effort ? `architect-${effort}` : 'architect' +} + +export function isArchitectEffort(value: string): value is ArchitectEffort { + return (ARCHITECT_EFFORTS as readonly string[]).includes(value) +} + +export function isTerminalArchitectStatus(status: ArchitectRunStatus): boolean { + return status !== 'queued' && status !== 'in_progress' +} + +/** Queues a background run and returns as soon as Pascal accepts it. */ +export async function startArchitectRun( + apiKey: string | undefined, + request: ArchitectRunRequest, + options: ArchitectRequestOptions = {}, +): Promise { + const body = runRequestBody(request, false) + const credential = requireAgentApiKey(apiKey) + return requestRun(credential, 'run', ARCHITECT_RESPONSES_ENDPOINT, 'POST', body, options) +} + +/** Queues a background run and streams it until it reaches a terminal status. */ +export async function streamArchitectRun( + apiKey: string | undefined, + request: ArchitectRunRequest, + options: ArchitectStreamOptions = {}, +): Promise { + const body = runRequestBody(request, true) + const credential = requireAgentApiKey(apiKey) + const { controller, headersReceived, clear } = requestController(options) + let runId: string | undefined + try { + const response = await send(credential, 'run', ARCHITECT_RESPONSES_ENDPOINT, 'POST', body, { + ...options, + controller, + stream: true, + }) + headersReceived() + if (!response.body) throw invalidResponse() + + let text = '' + for await (const event of readServerSentEvents(response.body, controller.signal)) { + const type = typeof event.type === 'string' ? event.type : '' + if (type === 'error') throw streamError(event, runId) + if (type === 'response.output_text.delta') { + if (typeof event.delta !== 'string') continue + const delta = sanitizeText(event.delta) + text += delta + options.onText?.(delta) + continue + } + if (!type.startsWith('response.') || !isRecord(event.response)) continue + const run = toArchitectRun(event.response) + if (!run) throw invalidResponse() + if (!runId) { + runId = run.id + options.onStarted?.(run) + } + if (isTerminalArchitectStatus(run.status)) { + return { ...run, outputText: run.outputText ?? (text || null) } + } + } + throw new CliError( + 'architect_stream_interrupted', + runId + ? `The stream ended before run ${runId} finished. It keeps running on Pascal; check it with "pascal architect status ${runId}".` + : 'The stream ended before Pascal confirmed the run. Try again.', + runId ? { id: runId } : undefined, + ) + } catch (error) { + if (options.signal?.aborted) throw detachedError(runId) + if (error instanceof CliError) throw error + if (controller.signal.aborted) throw timeoutError('run') + throw new CliError( + 'architect_stream_interrupted', + runId + ? `The connection dropped while following run ${runId}. It keeps running on Pascal; check it with "pascal architect status ${runId}".` + : 'The connection dropped before Pascal confirmed the run. Try again.', + runId ? { id: runId } : undefined, + ) + } finally { + clear() + } +} + +export async function getArchitectRun( + apiKey: string | undefined, + id: string, + options: ArchitectRequestOptions = {}, +): Promise { + const runId = requireRunId(id) + const credential = requireAgentApiKey(apiKey) + return requestRun(credential, 'status', runUrl(runId), 'GET', undefined, options) +} + +export async function cancelArchitectRun( + apiKey: string | undefined, + id: string, + options: ArchitectRequestOptions = {}, +): Promise { + const runId = requireRunId(id) + const credential = requireAgentApiKey(apiKey) + return requestRun(credential, 'cancel', `${runUrl(runId)}/cancel`, 'POST', undefined, options) +} + +function runRequestBody(request: ArchitectRunRequest, stream: boolean): string { + const brief = request.brief.trim() + if (!brief) + throw invalidOption( + 'Describe what the architect should design: pascal architect run "".', + ) + if (brief.length > MAX_BRIEF_LENGTH) { + throw invalidOption(`The brief must be at most ${MAX_BRIEF_LENGTH} characters.`) + } + if (request.effort !== undefined && !isArchitectEffort(request.effort)) { + throw invalidOption(`--effort must be one of ${ARCHITECT_EFFORTS.join(', ')}.`) + } + const metadata: Record = {} + if (request.projectId !== undefined) { + const projectId = request.projectId.trim() + if (!projectId || projectId.length > MAX_METADATA_VALUE_LENGTH) { + throw invalidOption(`--project must be 1 to ${MAX_METADATA_VALUE_LENGTH} characters.`) + } + metadata.project_id = projectId + } + if (request.maxCredits !== undefined) { + if (!Number.isSafeInteger(request.maxCredits) || request.maxCredits < 1) { + throw invalidOption('--max-credits must be a positive integer.') + } + metadata.pascal_max_credits = String(request.maxCredits) + } + return JSON.stringify({ + model: architectModel(request.effort), + input: brief, + background: true, + store: true, + stream, + ...(Object.keys(metadata).length > 0 ? { metadata } : {}), + }) +} + +function requireRunId(id: string): string { + const runId = id.trim() + if (!RESPONSE_ID_PATTERN.test(runId)) { + throw invalidOption('Pass the run ID printed by "pascal architect run".') + } + return runId +} + +function runUrl(id: string): string { + return `${ARCHITECT_RESPONSES_ENDPOINT}/${encodeURIComponent(id)}` +} + +async function requestRun( + credential: string, + action: ArchitectAction, + url: string, + method: 'GET' | 'POST', + body: string | undefined, + options: ArchitectRequestOptions, +): Promise { + const { controller, clear } = requestController(options) + try { + const response = await send(credential, action, url, method, body, { ...options, controller }) + let parsed: unknown + try { + parsed = JSON.parse(await readBoundedText(response, MAX_JSON_BYTES)) + } catch (error) { + if (options.signal?.aborted) throw detachedError(undefined) + if (controller.signal.aborted) throw timeoutError(action) + if (error instanceof CliError) throw error + throw invalidResponse() + } + const run = isRecord(parsed) ? toArchitectRun(parsed) : undefined + if (!run) throw invalidResponse() + return run + } finally { + clear() + } +} + +async function send( + credential: string, + action: ArchitectAction, + url: string, + method: 'GET' | 'POST', + body: string | undefined, + options: ArchitectRequestOptions & { controller: AbortController; stream?: boolean }, +): Promise { + let response: Response + try { + response = await (options.fetch ?? fetch)(url, { + method, + headers: { + Accept: options.stream ? 'text/event-stream' : 'application/json', + Authorization: `Bearer ${credential}`, + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body, + redirect: 'error', + signal: options.controller.signal, + }) + } catch { + if (options.signal?.aborted) throw detachedError(undefined) + if (options.controller.signal.aborted) throw timeoutError(action) + throw new CliError( + 'architect_unreachable', + 'Pascal could not be reached. Check the network connection and try again.', + ) + } + if (!response.ok) throw httpError(action, response.status, await readErrorCode(response)) + return response +} + +function requestController(options: ArchitectRequestOptions) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? DEFAULT_TIMEOUT_MS) + const onAbort = () => controller.abort() + options.signal?.addEventListener('abort', onAbort, { once: true }) + if (options.signal?.aborted) controller.abort() + return { + controller, + headersReceived: () => clearTimeout(timeout), + clear: () => { + clearTimeout(timeout) + options.signal?.removeEventListener('abort', onAbort) + }, + } +} + +async function readBoundedText(response: Response, maxBytes: number): Promise { + const declaredLength = Number(response.headers.get('content-length')) + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + if (response.body) void response.body.cancel().catch(() => {}) + throw invalidResponse() + } + if (!response.body) return '' + const reader = response.body.getReader() + const decoder = new TextDecoder() + let bytes = 0 + let text = '' + while (true) { + const chunk = await reader.read() + if (chunk.done) break + bytes += chunk.value.byteLength + if (bytes > maxBytes) { + void reader.cancel().catch(() => {}) + throw invalidResponse() + } + text += decoder.decode(chunk.value, { stream: true }) + } + return text + decoder.decode() +} + +async function readErrorCode(response: Response): Promise { + try { + const body = JSON.parse(await readBoundedText(response, MAX_ERROR_BYTES)) as unknown + if (!isRecord(body)) return undefined + const code = isRecord(body.error) ? body.error.code : (body.code ?? body.error) + return typeof code === 'string' && ERROR_CODE_PATTERN.test(code) ? code : undefined + } catch { + return undefined + } +} + +async function* readServerSentEvents( + body: ReadableStream, + signal: AbortSignal, +): AsyncGenerator> { + const reader = body.getReader() + const decoder = new TextDecoder() + const abort = () => void reader.cancel().catch(() => {}) + signal.addEventListener('abort', abort, { once: true }) + let buffer = '' + try { + while (true) { + const chunk = await reader.read() + if (signal.aborted) throw signal.reason + buffer += chunk.done ? decoder.decode() : decoder.decode(chunk.value, { stream: true }) + const blocks = buffer.split(/\r?\n\r?\n/) + buffer = chunk.done ? '' : (blocks.pop() ?? '') + if (buffer.length > MAX_JSON_BYTES) throw invalidResponse() + for (const block of blocks) { + const data = block + .split(/\r?\n/) + .filter((line) => line.startsWith('data:')) + .map((line) => line.slice(line.startsWith('data: ') ? 6 : 5)) + .join('\n') + if (!data || data === '[DONE]') continue + let event: unknown + try { + event = JSON.parse(data) + } catch { + throw invalidResponse() + } + if (isRecord(event)) yield event + } + if (chunk.done) return + } + } finally { + signal.removeEventListener('abort', abort) + reader.releaseLock() + } +} + +function toArchitectRun(value: Record): ArchitectRun | undefined { + if (typeof value.id !== 'string' || !RESPONSE_ID_PATTERN.test(value.id)) return undefined + const status = value.status + if (typeof status !== 'string' || !(RUN_STATUSES as readonly string[]).includes(status)) { + return undefined + } + const metadata = isRecord(value.metadata) ? value.metadata : {} + const maxCredits = Number(metadata.pascal_max_credits) + return { + schemaVersion: 1, + id: value.id, + status: status as ArchitectRunStatus, + model: typeof value.model === 'string' ? sanitizeLine(value.model) : null, + projectId: typeof metadata.project_id === 'string' ? sanitizeLine(metadata.project_id) : null, + maxCredits: Number.isSafeInteger(maxCredits) && maxCredits > 0 ? maxCredits : null, + createdAt: + typeof value.created_at === 'number' && Number.isFinite(value.created_at) + ? new Date(value.created_at * 1000).toISOString() + : null, + outputText: outputText(value), + error: runError(value), + } +} + +function outputText(value: Record): string | null { + if (typeof value.output_text === 'string') return sanitizeText(value.output_text) + if (!Array.isArray(value.output)) return null + const parts: string[] = [] + for (const item of value.output) { + if (!isRecord(item) || item.type !== 'message' || !Array.isArray(item.content)) continue + for (const part of item.content) { + if (isRecord(part) && part.type === 'output_text' && typeof part.text === 'string') { + parts.push(part.text) + } + } + } + return parts.length > 0 ? sanitizeText(parts.join('')) : null +} + +function runError(value: Record): ArchitectRun['error'] { + const error = isRecord(value.error) ? value.error : undefined + const incomplete = isRecord(value.incomplete_details) ? value.incomplete_details : undefined + if (!error && !incomplete) return null + const code = error?.code + const message = error?.message ?? incomplete?.reason + return { + code: typeof code === 'string' && ERROR_CODE_PATTERN.test(code) ? code : null, + message: + typeof message === 'string' + ? sanitizeLine(message).slice(0, MAX_SERVER_MESSAGE_LENGTH) + : null, + } +} + +/** Server text reaches a terminal, so it keeps line breaks and tabs but no control sequences. */ +function sanitizeText(value: string): string { + // biome-ignore lint/suspicious/noControlCharactersInRegex: stripping them is the point + return value.replace(/\r\n?/g, '\n').replace(/[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g, '') +} + +function sanitizeLine(value: string): string { + return sanitizeText(value).replace(/\s+/g, ' ').trim() +} + +function streamError(event: Record, runId: string | undefined): CliError { + const nested = isRecord(event.error) ? event.error : event + const code = + typeof nested.code === 'string' && ERROR_CODE_PATTERN.test(nested.code) + ? nested.code + : undefined + if (code === 'executor_unavailable') return executorUnavailable('run', undefined) + return new CliError( + 'architect_run_error', + runId + ? `Pascal reported an error while streaming run ${runId}. Check it with "pascal architect status ${runId}".` + : 'Pascal reported an error before the run started. Try again.', + { ...(runId ? { id: runId } : {}), ...(code ? { code } : {}) }, + ) +} + +function httpError(action: ArchitectAction, status: number, code: string | undefined): CliError { + const details = { status, ...(code ? { code } : {}) } + if (code === 'executor_unavailable') return executorUnavailable(action, status) + switch (status) { + case 400: + return new CliError( + 'architect_invalid_request', + `Pascal rejected the architect request${code ? ` (${code})` : ''}. Check the brief and options.`, + details, + ) + case 401: + return new CliError( + 'architect_unauthorized', + 'PASCAL_API_KEY is invalid or revoked. Check it with "pascal agent status".', + details, + ) + case 402: + return new CliError( + 'architect_insufficient_credits', + 'The account behind PASCAL_API_KEY does not have enough Pascal credits for this run.', + details, + ) + case 403: + return new CliError( + 'architect_forbidden', + 'PASCAL_API_KEY is not allowed to use the Pascal architect. Check it with "pascal agent status".', + details, + ) + case 404: + if (action !== 'run') { + return new CliError( + 'architect_run_not_found', + 'No architect run with that ID is visible to this PASCAL_API_KEY.', + details, + ) + } + break + case 429: + return new CliError( + 'architect_rate_limited', + 'Too many architect requests. Wait and try again.', + details, + ARCHITECT_RETRY_EXIT_CODE, + ) + case 503: + return new CliError( + 'architect_unavailable', + 'The Pascal architect is temporarily unavailable. Try again shortly.', + details, + ARCHITECT_RETRY_EXIT_CODE, + ) + } + return new CliError( + 'architect_failed', + `Pascal could not ${action === 'run' ? 'start the architect run' : action === 'status' ? 'read the architect run' : 'cancel the architect run'} (HTTP ${status}).`, + details, + ) +} + +function executorUnavailable(action: ArchitectAction, status: number | undefined): CliError { + return new CliError( + 'architect_executor_unavailable', + action === 'run' + ? 'No Pascal architect executor is available right now, so the run was not started. Try again in a few minutes.' + : 'No Pascal architect executor is available right now. Try again in a few minutes.', + { ...(status ? { status } : {}), code: 'executor_unavailable' }, + ARCHITECT_RETRY_EXIT_CODE, + ) +} + +function detachedError(runId: string | undefined): CliError { + return new CliError( + 'architect_detached', + runId + ? `Detached from run ${runId}; it keeps running on Pascal. Check it with "pascal architect status ${runId}" or stop it with "pascal architect cancel ${runId}".` + : 'Stopped before Pascal confirmed the run.', + runId ? { id: runId } : undefined, + 130, + ) +} + +function timeoutError(action: ArchitectAction): CliError { + return new CliError( + 'architect_timeout', + action === 'run' + ? 'Pascal did not respond while starting the architect run. Try again.' + : 'Pascal did not respond. Try again.', + ) +} + +function invalidResponse(): CliError { + return new CliError( + 'architect_invalid_response', + 'Pascal returned an invalid architect response. Try again.', + ) +} + +function invalidOption(message: string): CliError { + return new CliError('invalid_option', message, undefined, 2) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/cli/src/bin/pascal.ts b/packages/cli/src/bin/pascal.ts index feda370640..148fe7509b 100755 --- a/packages/cli/src/bin/pascal.ts +++ b/packages/cli/src/bin/pascal.ts @@ -2,6 +2,16 @@ import { spawn } from 'node:child_process' import { parseArgs } from 'node:util' import { agentClaimHandoffUrl, getAgentStatus, startAgentClaim } from '../agent-account.js' +import { + ARCHITECT_EFFORTS, + type ArchitectRun, + architectModel, + cancelArchitectRun, + getArchitectRun, + isArchitectEffort, + startArchitectRun, + streamArchitectRun, +} from '../architect.js' import { openBrowser } from '../browser.js' import { installGlobalPascalCommand, isNpxInvocation } from '../command-install.js' import { collectInfo, runDoctor } from '../diagnostics.js' @@ -54,6 +64,9 @@ USAGE: pascal project resume [id-or-name] pascal agent claim [--no-open] [--json] pascal agent status [--json] + pascal architect run "" [--effort ] [--project ] [--max-credits ] [--detach] [--json] + pascal architect status [--json] + pascal architect cancel [--json] pascal mcp connect | status | config | setup pascal plugin list [--json] @@ -106,6 +119,30 @@ account's private projects. Documentation: https://editor.pascal.app/docs/developers/mcp ` +const ARCHITECT_HELP = `Pascal architect — design a building from a brief on hosted Pascal + +USAGE: + pascal architect run "" [options] Start a run and stream its output + pascal architect status [--json] Show a run's status and output + pascal architect cancel [--json] Cancel a queued or running run + +RUN OPTIONS: + --effort low|medium|high|pro Model architect- (default: architect) + --project Hosted project the run works in + --max-credits Stop the run before it spends more than n credits + --detach Print the run ID and return without streaming + --json Print the run as JSON (waits unless --detach) + +Set PASCAL_API_KEY to the agent's hosted Pascal API key; the CLI never stores +it. Runs execute in the background on Pascal: pressing Ctrl+C while streaming +detaches without cancelling, and "pascal architect status " picks it up. + +Exit codes: 0 completed, 1 failed or error, 2 invalid usage, 75 temporarily +unavailable (no executor, rate limited) and safe to retry, 130 detached. + +Documentation: https://editor.pascal.app/docs/developers/mcp +` + const paths = resolvePascalPaths() const agentApiKey = process.env.PASCAL_API_KEY Reflect.deleteProperty(process.env, 'PASCAL_API_KEY') @@ -115,7 +152,15 @@ async function main(): Promise { if (command === '--version' || command === '-v') return print(version) if (command === '--help' || command === '-h' || command === 'help') return print(HELP) if (args.includes('--help') || args.includes('-h')) { - return print(command === 'mcp' ? MCP_HELP : command === 'agent' ? AGENT_HELP : HELP) + return print( + command === 'mcp' + ? MCP_HELP + : command === 'agent' + ? AGENT_HELP + : command === 'architect' + ? ARCHITECT_HELP + : HELP, + ) } switch (command) { @@ -147,6 +192,8 @@ async function main(): Promise { return runProject(args) case 'agent': return runAgent(args, agentApiKey) + case 'architect': + return runArchitect(args, agentApiKey) case 'plugin': return runPlugin(args) case 'mcp': @@ -730,6 +777,137 @@ async function runAgent(args: string[], apiKey: string | undefined): Promise { + const [subcommand, ...rest] = args + if (subcommand === 'status' || subcommand === 'cancel') { + const { values, positionals } = parseArgs({ + args: rest, + strict: true, + allowPositionals: true, + options: { json: { type: 'boolean', default: false } }, + }) + if (positionals.length !== 1) { + throw new CliError( + 'invalid_option', + `Use "pascal architect ${subcommand} ".`, + undefined, + 2, + ) + } + const id = positionals[0] ?? '' + const run = + subcommand === 'status' + ? await getArchitectRun(apiKey, id) + : await cancelArchitectRun(apiKey, id) + output(values.json, run, describeArchitectRun(run, subcommand === 'status')) + return + } + if (subcommand !== 'run') { + throw new CliError( + 'unknown_command', + 'Use "pascal architect run", "pascal architect status ", or "pascal architect cancel ".', + undefined, + 2, + ) + } + + const { values, positionals } = parseArgs({ + args: rest, + strict: true, + allowPositionals: true, + options: { + effort: { type: 'string' }, + project: { type: 'string' }, + 'max-credits': { type: 'string' }, + detach: { type: 'boolean', default: false }, + json: { type: 'boolean', default: false }, + }, + }) + if (values.effort !== undefined && !isArchitectEffort(values.effort)) { + throw new CliError( + 'invalid_option', + `--effort must be one of ${ARCHITECT_EFFORTS.join(', ')}.`, + undefined, + 2, + ) + } + const request = { + brief: positionals.join(' '), + effort: values.effort, + projectId: values.project, + maxCredits: parseIntegerOption(values['max-credits'], 'max-credits'), + } + + if (values.detach) { + const run = await startArchitectRun(apiKey, request) + output( + values.json, + run, + [ + `Architect run ${run.id} is ${formatRunStatus(run.status)} on ${run.model ?? architectModel(request.effort)}.`, + `Follow it: pascal architect status ${run.id}`, + `Cancel it: pascal architect cancel ${run.id}`, + ].join('\n'), + ) + return + } + + const detach = new AbortController() + const onInterrupt = () => detach.abort() + process.once('SIGINT', onInterrupt) + let streamedText = false + let endsWithNewline = true + let run: ArchitectRun + try { + run = await streamArchitectRun(apiKey, request, { + signal: detach.signal, + onStarted: (started) => { + if (values.json) return + process.stderr.write( + `Architect run ${started.id} started on ${started.model ?? architectModel(request.effort)}. Ctrl+C detaches; the run keeps going.\n`, + ) + }, + onText: (delta) => { + if (values.json || !delta) return + process.stdout.write(delta) + streamedText = true + endsWithNewline = delta.endsWith('\n') + }, + }) + } finally { + process.off('SIGINT', onInterrupt) + if (!endsWithNewline) process.stdout.write('\n') + } + + if (values.json) { + print(JSON.stringify(run, null, 2)) + } else { + if (!streamedText && run.outputText) print(run.outputText) + process.stderr.write(`${describeArchitectRun(run, false)}\n`) + } + if (run.status !== 'completed') process.exitCode = 1 +} + +function describeArchitectRun(run: ArchitectRun, includeOutput: boolean): string { + const lines = [ + `Run: ${run.id}`, + `Status: ${formatRunStatus(run.status)}`, + ...(run.model ? [`Model: ${run.model}`] : []), + ...(run.projectId ? [`Project: ${run.projectId}`] : []), + ...(run.maxCredits ? [`Max credits: ${run.maxCredits}`] : []), + ...(run.createdAt ? [`Created: ${run.createdAt}`] : []), + ...(run.error + ? [`Error: ${[run.error.code, run.error.message].filter(Boolean).join(': ') || 'unknown'}`] + : []), + ] + if (includeOutput && run.outputText) lines.push('', run.outputText) + return lines.join('\n') +} + +function formatRunStatus(status: ArchitectRun['status']): string { + return status.replace('_', ' ') +} + async function runPlugin(args: string[]): Promise { const [subcommand, ...rest] = args if (subcommand === 'list') { diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index 706a993321..d71ae01d36 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -7,6 +7,29 @@ const executable = path.join(import.meta.dir, 'bin/pascal.ts') const testRoot = await mkdtemp(path.join(os.tmpdir(), 'pascal-cli-command-test-')) const testHome = path.join(testRoot, 'home') const claimFetchPreload = path.join(testRoot, 'claim-fetch-preload.mjs') +const architectFetchPreload = path.join(testRoot, 'architect-fetch-preload.mjs') + +await writeFile( + architectFetchPreload, + `globalThis.fetch = async (input, init) => { + if (init?.redirect !== 'error') throw new Error('Redirects must be disabled') + if (process.env.PASCAL_API_KEY !== undefined) { + throw new Error('PASCAL_API_KEY remained in the process environment') + } + const request = { method: init?.method, url: String(input), body: init?.body ? JSON.parse(init.body) : null } + if (JSON.stringify(request) !== process.env.PASCAL_ARCHITECT_TEST_REQUEST) { + throw new Error('Unexpected architect request ' + JSON.stringify(request)) + } + if (new Headers(init?.headers).get('authorization') !== 'Bearer sk_live_architect-cli-key') { + throw new Error('Unexpected architect authorization') + } + return new Response(process.env.PASCAL_ARCHITECT_TEST_BODY, { + headers: { 'content-type': process.env.PASCAL_ARCHITECT_TEST_CONTENT_TYPE }, + status: Number(process.env.PASCAL_ARCHITECT_TEST_STATUS), + }) + } +`, +) await writeFile( claimFetchPreload, @@ -182,6 +205,154 @@ describe('command parsing', () => { expect(JSON.parse(result.stderr)).toMatchObject({ error: 'unknown_command' }) }) + test('shows focused help for hosted architect runs', async () => { + const result = await runCli('architect', '--help') + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('pascal architect run ""') + expect(result.stdout).toContain('--effort low|medium|high|pro') + expect(result.stdout).not.toContain('pascal plugin list') + }) + + test('rejects an unknown architect effort before contacting Pascal', async () => { + const result = await runCli('architect', 'run', 'A cabin', '--effort', 'max', '--json') + + expect(result.exitCode).toBe(2) + expect(JSON.parse(result.stderr)).toMatchObject({ error: 'invalid_option' }) + }) + + test('detaches a background run and prints the stable JSON run contract', async () => { + const result = await runArchitectCli( + { + method: 'POST', + url: 'https://editor.pascal.app/api/v1/responses', + body: { + model: 'architect-pro', + input: 'A courtyard house', + background: true, + store: true, + stream: false, + metadata: { project_id: 'proj_cli', pascal_max_credits: '25' }, + }, + }, + 200, + { + id: 'resp_cli', + status: 'queued', + model: 'architect-pro', + metadata: { project_id: 'proj_cli', pascal_max_credits: '25' }, + }, + ['run', 'A', 'courtyard house', '--effort', 'pro', '--project', 'proj_cli'], + ['--max-credits', '25', '--detach', '--json'], + ) + + expect(result.exitCode).toBe(0) + expect(JSON.parse(result.stdout)).toEqual({ + schemaVersion: 1, + id: 'resp_cli', + status: 'queued', + model: 'architect-pro', + projectId: 'proj_cli', + maxCredits: 25, + createdAt: null, + outputText: null, + error: null, + }) + expect(result.stderr).toBe('') + }) + + test('streams architect output to stdout and progress to stderr', async () => { + const queued = { id: 'resp_cli', status: 'queued', model: 'architect' } + const events = [ + { type: 'response.created', response: queued }, + { type: 'response.output_text.delta', delta: 'Placed four walls.' }, + { type: 'response.completed', response: { ...queued, status: 'completed' } }, + ] + const result = await runArchitectCli( + { + method: 'POST', + url: 'https://editor.pascal.app/api/v1/responses', + body: { model: 'architect', input: 'A shed', background: true, store: true, stream: true }, + }, + 200, + events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join(''), + ['run', 'A shed'], + [], + 'text/event-stream', + ) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toBe('Placed four walls.\n') + expect(result.stderr).toContain('Architect run resp_cli started on architect.') + expect(result.stderr).toContain('Status: completed') + }) + + test('reports a missing executor as a retryable error', async () => { + const result = await runArchitectCli( + { + method: 'POST', + url: 'https://editor.pascal.app/api/v1/responses', + body: { + model: 'architect', + input: 'A shed', + background: true, + store: true, + stream: false, + }, + }, + 503, + { error: { type: 'server_error', code: 'executor_unavailable', message: 'busy' } }, + ['run', 'A shed', '--detach'], + ['--json'], + ) + + expect(result.exitCode).toBe(75) + expect(JSON.parse(result.stderr)).toMatchObject({ + error: 'architect_executor_unavailable', + details: { status: 503, code: 'executor_unavailable' }, + }) + expect(result.stdout).toBe('') + }) + + test('prints a failed run status with exit code 0 and its bounded error', async () => { + const result = await runArchitectCli( + { method: 'GET', url: 'https://editor.pascal.app/api/v1/responses/resp_cli', body: null }, + 200, + { + id: 'resp_cli', + status: 'failed', + model: 'architect', + error: { code: 'credit_limit_reached', message: 'Stopped at\u001b[2J the limit.' }, + }, + ['status', 'resp_cli'], + ) + + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Status: failed') + expect(result.stdout).toContain('Error: credit_limit_reached: Stopped at[2J the limit.') + expect(result.stdout).not.toContain('\u001b') + }) + + test('maps a revoked credential on cancel to the auth error contract', async () => { + const result = await runArchitectCli( + { + method: 'POST', + url: 'https://editor.pascal.app/api/v1/responses/resp_cli/cancel', + body: null, + }, + 401, + 'untrusted', + ['cancel', 'resp_cli'], + ['--json'], + ) + + expect(result.exitCode).toBe(1) + expect(JSON.parse(result.stderr)).toMatchObject({ + error: 'architect_unauthorized', + details: { status: 401 }, + }) + }) + test('rejects a partially numeric port', async () => { const result = await runCli('editor', '--port', '3000junk', '--no-open', '--json') @@ -311,3 +482,44 @@ async function runStatusCli(status: number, body: unknown, ...args: string[]) { ]) return { exitCode, stdout, stderr } } + +async function runArchitectCli( + request: { method: string; url: string; body: unknown }, + status: number, + body: unknown, + args: string[], + extraArgs: string[] = [], + contentType = 'application/json', +) { + const child = Bun.spawn( + [ + process.execPath, + '--preload', + architectFetchPreload, + executable, + 'architect', + ...args, + ...extraArgs, + ], + { + env: { + ...process.env, + PASCAL_API_KEY: 'sk_live_architect-cli-key', + PASCAL_ARCHITECT_TEST_BODY: typeof body === 'string' ? body : JSON.stringify(body), + PASCAL_ARCHITECT_TEST_CONTENT_TYPE: contentType, + PASCAL_ARCHITECT_TEST_REQUEST: JSON.stringify(request), + PASCAL_ARCHITECT_TEST_STATUS: String(status), + PASCAL_HOME: testHome, + PASCAL_NO_OPEN: '1', + }, + stdout: 'pipe', + stderr: 'pipe', + }, + ) + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + new Response(child.stderr).text(), + ]) + return { exitCode, stdout, stderr } +} From 7ee7040c9b35a6322246ae21ef3329cd9230a3fb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 20 Oct 2026 04:27:48 +0000 Subject: [PATCH 3/3] cli: document pascal architect commands in the README --- packages/cli/README.md | 58 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/cli/README.md b/packages/cli/README.md index 973f553806..cd05e2d079 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -160,6 +160,9 @@ downloaded the web runtime. | `pascal project open ` | Explicit form of `pascal open `. | | `pascal agent claim [--no-open] [--json]` | Link an autonomous hosted agent to the person accountable for it. | | `pascal agent status [--json]` | Verify the hosted agent credential and inspect its claim and organization scope. | +| `pascal architect run ""` | Start a hosted architect run from a brief and stream its output. | +| `pascal architect status [--json]` | Show a hosted architect run's status and output. | +| `pascal architect cancel [--json]` | Cancel a queued or running hosted architect run. | | `pascal mcp connect` | Stable local connector for MCP clients; starts the bundled MCP service without the web runtime. | | `pascal mcp status [--json]` | Show managed MCP health. | | `pascal mcp config [--json]` | Print generic MCP client configuration. | @@ -234,6 +237,61 @@ ownership, grant the agent access to the person's private projects, or grant the to the agent's private projects. The local editor and its projects remain local unless a separate hosted project action explicitly moves data. +## Hosted architect runs + +The `pascal architect` commands ship in the next `@pascal-app/cli` release; the version +currently on npm does not include them. + +You hand Pascal's hosted architect a brief, and it designs the building on Pascal while you +watch, detach, or come back later. You need an agent API key in `PASCAL_API_KEY`, the same +key `pascal agent status` checks. The CLI sends it to Pascal and never stores or prints it. + +Start a run and stream its output: + +```bash +PASCAL_API_KEY='sk_live_...' pascal architect run "A two-storey, three-bedroom house with an open-plan kitchen" +``` + +The design text streams to stdout and progress lines go to stderr, so you can pipe or +redirect the output. The run executes in the background on Pascal: when you press Ctrl+C you +detach, and the run keeps going. The CLI prints the run ID when it starts. + +| Flag | Effect | +| --- | --- | +| `--effort low\|medium\|high\|pro` | Use the `architect-` model. Without it, the run uses `architect`. | +| `--project ` | Run in a hosted Pascal project; sent as `metadata.project_id`. | +| `--max-credits ` | Cap the credits this run may spend; sent as `metadata.pascal_max_credits`. | +| `--detach` | Queue the run, print its ID, and return without streaming. | +| `--json` | Print the run as JSON; waits for the run to finish unless you also pass `--detach`. | + +Check on a run, including one you detached from: + +```bash +PASCAL_API_KEY='sk_live_...' pascal architect status resp_abc123 +``` + +You get the status (`queued`, `in progress`, `completed`, `failed`, `cancelled`, or +`incomplete`), the model, project, credit cap, and the output so far. `--json` returns the +same fields as a stable object with `schemaVersion: 1`. + +Stop a run you no longer need: + +```bash +PASCAL_API_KEY='sk_live_...' pascal architect cancel resp_abc123 +``` + +When something goes wrong, the CLI tells you whether retrying can help. Exit code `75` means +Pascal is temporarily unable to take the request: `architect_executor_unavailable` (no +architect executor is free, so no run started), `architect_rate_limited`, or +`architect_unavailable`. Retry the same command later. `architect_unauthorized` and +`architect_forbidden` mean the key is invalid, revoked, or not allowed to use the architect; +check it with `pascal agent status`. `architect run` exits `1` when the run ends in any state +other than `completed`, `2` on invalid usage, and `130` when you detach with Ctrl+C. With +`--json`, errors go to stderr as `{ "error": "", "message": "...", "details": { ... } }`. + +The commands are a thin client over Pascal's OpenAI-compatible Responses API at +`https://editor.pascal.app/api/v1/responses`, using background mode. + ## Plugins The current CLI manages the local editor runtime; it does not yet download plugin code