headless.ts 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. /**
  2. * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
  3. * boots the same base plus Web overlay as `dsh web` (port 0, so parallel runs never
  4. * collide), then in-process isomorphic injection (InProcessApiClient over
  5. * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire
  6. * serialization, zod, SSE framing — really runs). The printed URL opens the
  7. * live session in a browser while the task runs. Runs one task turn, prints
  8. * the final assistant text, exits (completed → 0, else 1).
  9. */
  10. import { fileURLToPath } from 'node:url'
  11. import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
  12. import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
  13. import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  14. import type { SessionId } from '@deepseek-ai/dsh-session'
  15. import { AppCLIEntry } from './app-cli-entry.ts'
  16. import { createProcessShutdown } from './process-shutdown.ts'
  17. /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
  18. interface TurnOutcome {
  19. text: string
  20. reason: string
  21. }
  22. /** Unwrap an RpcResponse or fail loud: business errors print and exit 1 (shutdown first). */
  23. async function unwrap<T>(response: RpcResponse<T>, shutdown: () => Promise<void>): Promise<T> {
  24. if (response.result.ok) return response.result.value
  25. const { code, message } = response.result.error
  26. process.stderr.write(`dsh: ${code}: ${message}\n`)
  27. await shutdown()
  28. process.exit(1)
  29. }
  30. /**
  31. * Consume mux frames until the agent reaches idle, per the one-shot CLI
  32. * idle-to-idle contract: the stream opens immediately before the prompt, and
  33. * its first observed turn/start begins the task. Text is the last committed
  34. * assistant message of the whole interval (steering or injected work may run
  35. * further turns before quiescence), and the outcome reason is the final
  36. * turn/end's kind. Idleness is signalled out of band by the caller's
  37. * `agent/status` subscription; the stream itself carries no status frame.
  38. * @param frames - the mux stream opened before the prompt.
  39. * @param sessionId - the headless session.
  40. * @param idle - resolves when the agent reaches quiescence.
  41. * @returns the aggregated outcome.
  42. */
  43. async function consumeUntilIdle(
  44. frames: AsyncIterable<RpcRequest<MuxFrame>>,
  45. sessionId: SessionId,
  46. idle: Promise<void>,
  47. ): Promise<TurnOutcome> {
  48. let started = false
  49. let text = ''
  50. let reason: string = 'error'
  51. void (async () => {
  52. try {
  53. for await (const frame of frames) {
  54. const payload = frame.payload
  55. if (payload.type === 'stream/error') return
  56. if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
  57. const event = payload.event
  58. if (event.type === 'turn/start') {
  59. started = true
  60. continue
  61. }
  62. if (!started) continue
  63. if (event.type === 'assistant/message') {
  64. const joined = event.data.message.content.filter(block => block.type === 'text').map(block => block.text).join('')
  65. if (joined !== '') text = joined
  66. }
  67. if (event.type === 'turn/end') reason = event.data.reason.kind
  68. }
  69. } catch (error: unknown) {
  70. process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
  71. }
  72. })()
  73. await idle
  74. return { text, reason }
  75. }
  76. /**
  77. * Run one headless turn for `task` and exit (completed → 0, else 1). The task
  78. * is the non-empty prompt the argument adapter parsed from `-p`/`--prompt`
  79. * (the adapter rejects an empty task, so no guard is needed here).
  80. * @param task - the prompt text for the single turn.
  81. */
  82. export async function runHeadless(task: string): Promise<void> {
  83. // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
  84. const entry = new AppCLIEntry({
  85. configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
  86. overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
  87. dev: false,
  88. watchPersonalConfig: false,
  89. port: 0,
  90. })
  91. const { ctx, port } = await entry.run()
  92. // Normal completion and signals share one bounded drain. A signal received
  93. // during that drain escalates immediately instead of becoming a no-op.
  94. const shutdown = createProcessShutdown(async () => { await ctx.fiber.dispose() })
  95. process.on('SIGTERM', () => { shutdown.interrupt(143) })
  96. process.on('SIGINT', () => { shutdown.interrupt(130) })
  97. // The headless session is web-observable while it runs (same composition).
  98. process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
  99. const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
  100. const created = await unwrap(await api.sessions.create({}), () => shutdown.shutdown(1))
  101. // Open the stream before prompting so no frame is lost — kept in this order
  102. // even though in-process delivery has no race, so the code survives a move
  103. // to a remote HTTP carrier unchanged.
  104. const abort = new AbortController()
  105. const frames = api.events.mux({}, abort.signal)
  106. const idle = new Promise<void>((resolve) => {
  107. ctx.on('agent/status', ({ agent, status }) => {
  108. if (agent.id === created.sessionId && status === 'idle') resolve()
  109. })
  110. })
  111. const done = consumeUntilIdle(frames, created.sessionId, idle)
  112. await unwrap(await api.sessions.prompt({
  113. sessionId: created.sessionId,
  114. mode: 'queue',
  115. content: [{ type: 'text', text: task }],
  116. }), () => shutdown.shutdown(1))
  117. const outcome = await done
  118. process.stdout.write(outcome.text + '\n')
  119. abort.abort()
  120. await shutdown.shutdown(outcome.reason === 'completed' ? 0 : 1)
  121. }