/** * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry * boots the same base plus Web overlay as `dsh web` (port 0, so parallel runs never * collide), then in-process isomorphic injection (InProcessApiClient over * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire * serialization, zod, SSE framing — really runs). The printed URL opens the * live session in a browser while the task runs. Runs one task turn, prints * the final assistant text, exits (completed → 0, else 1). */ import { fileURLToPath } from 'node:url' import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' import { AppCLIEntry } from './app-cli-entry.ts' import { createProcessShutdown } from './process-shutdown.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { text: string reason: string } /** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */ async function unwrap(response: RpcResponse, shutdown: () => Promise): Promise { if (response.result.ok) return response.result.value const { code, message } = response.result.error process.stderr.write(`dsh: ${code}: ${message}\n`) await shutdown() process.exit(1) } /** * Consume mux frames until the agent reaches idle, per the one-shot CLI * idle-to-idle contract: the stream opens immediately before the prompt, and * its first observed turn/start begins the task. Text is the last committed * assistant message of the whole interval (steering or injected work may run * further turns before quiescence), and the outcome reason is the final * turn/end's kind. Idleness is signalled out of band by the caller's * `agent/status` subscription; the stream itself carries no status frame. * @param frames - the mux stream opened before the prompt. * @param sessionId - the headless session. * @param idle - resolves when the agent reaches quiescence. * @returns the aggregated outcome. */ async function consumeUntilIdle( frames: AsyncIterable>, sessionId: SessionId, idle: Promise, ): Promise { let started = false let text = '' let reason: string = 'error' void (async () => { try { for await (const frame of frames) { const payload = frame.payload if (payload.type === 'stream/error') return if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue const event = payload.event if (event.type === 'turn/start') { started = true continue } if (!started) continue if (event.type === 'assistant/message') { const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('') if (joined !== '') text = joined } if (event.type === 'turn/end') reason = event.data.reason.kind } } catch (error: unknown) { process.stderr.write(`dsh: event stream failed: ${String(error)}\n`) } })() await idle return { text, reason } } /** * Run one headless turn for `task` and exit (completed → 0, else 1). The task * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt` * (the adapter rejects an empty task, so no guard is needed here). * @param task - the prompt text for the single turn. */ export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)), overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)), dev: false, watchPersonalConfig: false, port: 0, }) const { ctx, port } = await entry.run() // Normal completion and signals share one bounded drain. A signal received // during that drain escalates immediately instead of becoming a no-op. const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() }) process.on('SIGTERM', () => { shutdown.interrupt(143) }) process.on('SIGINT', () => { shutdown.interrupt(130) }) // The headless session is web-observable while it runs (same composition). process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy)) const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1)) // Open the stream before prompting so no frame is lost — kept in this order // even though in-process delivery has no race, so the code survives a move // to a remote HTTP carrier unchanged. const abort = new AbortController() const frames = api.events.mux({}, abort.signal) const idle = new Promise((resolve) => { ctx.on('agent/status', ({ agent, status }) => { if (agent.id === created.sessionId && status === 'idle') resolve() }) }) const done = consumeUntilIdle(frames, created.sessionId, idle) await unwrap(await api.sessions.prompt({ sessionId: created.sessionId, mode: 'queue', content: [{ type: 'text', text: task }], }), () => shutdown.shutdown(1)) const outcome = await done process.stdout.write(outcome.text + '\n') abort.abort() await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1) }