feat(bundle): ship dsh-base, dsh-web-app, and dsh-headless profile bundles

Profile bundles are npm packages declaring dsh.patch in their manifest:
dsh-base carries the former base.cordis.yml rows as one insert over the empty
profile root; dsh-web-app carries the web overlay plus a runtime glue plugin
owning what used to be launcher code (frontend dist resolution via
frontend-static, the web-surface prompt section, bash runtime variables, the
readiness-gated URL line); dsh-headless carries the one-shot runner driving a
task turn through the in-process API carrier under the launcher-provided
ctx.headlessIo seam.
This commit is contained in:
Turtle
2026-08-06 04:40:11 +08:00
parent 2ee2ee2f96
commit 2365b2c54f
39 files changed
+2282 -327

No files matched your search

+147
View File
@@ -0,0 +1,147 @@
/**
* @deepseek-ai/dsh-headless — the one-shot headless bundle: the bundle patch
* (`cordis.patch.yml`) rides over dsh-base + dsh-web-app (the headless
* session is web-observable while it runs — same composition), and this
* runner plugin drives one task turn through the in-process API carrier
* (InProcessApiClient over toFetchHandler(ctx.apiProxy), so the full wire
* chain — serialization, zod, SSE framing — really runs), prints the final
* assistant text, and exits (completed → 0, else 1). The task text arrives as
* launcher-patched config (`dsh --profile headless "task"`).
* @module @deepseek-ai/dsh-headless
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { InProcessApiClient, toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
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'
/** Stable Cordis plugin name. */
export const name = 'headless-runner'
/** Services required before the one-shot turn can start. */
export const inject = ['apiProxy', 'httpServer']
/** Plugin config: the task, patched in by the launcher. */
export interface Config {
/** The prompt text for the single turn. */
task: string
}
export const Config: z<Config> = z.object({
task: z.string().required(),
})
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
interface TurnOutcome {
text: string
reason: string
}
/**
* The process-facing effects of one run, injectable for tests: output
* streams and the exit request (the launcher wires it to its bounded
* shutdown controller).
*/
export interface HeadlessIo {
stdout: { write(chunk: string): unknown }
stderr: { write(chunk: string): unknown }
/** Request process exit with `code` after the tree disposes. */
exit(code: number): void
}
/** Host seam: the launcher provides the exit wiring before the tree mounts. */
declare module 'cordis' {
interface Context {
/** Process-facing effects for the one-shot headless runner. */
headlessIo?: HeadlessIo
}
}
/** Unwrap an RpcResponse or fail loud: business errors print and exit 1. */
async function unwrap<T>(response: RpcResponse<T>, io: HeadlessIo): Promise<T> {
if (response.result.ok) return response.result.value
const { code, message } = response.result.error
io.stderr.write(`dsh: ${code}: ${message}\n`)
io.exit(1)
// Exit is asynchronous (bounded tree disposal); park this turn forever so
// no further request rides a session that is already being torn down.
return new Promise<never>(() => {})
}
/**
* Consume mux frames until the task turn ends: anchor on the first turn/start
* whose trigger kind is 'message' (startup-injected turns are skipped),
* aggregate text from that turn's assistant/message events (last one wins),
* finish on its turn/end.
*/
async function consumeUntilTurnEnd(
frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId, io: HeadlessIo,
): Promise<TurnOutcome> {
let targetTurn: number | undefined
let text = ''
try {
for await (const frame of frames) {
const payload = frame.payload
if (payload.type === 'stream/error') {
io.stderr.write(`dsh: stream error: ${payload.error.message}\n`)
return { text, reason: 'error' }
}
if (payload.type !== 'session/event' || payload.sessionId !== sessionId) continue
const event = payload.event
if (targetTurn === undefined) {
if (event.type === 'turn/start' && event.data.trigger.kind === 'message') targetTurn = event.data.turn
continue
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
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' && event.data.turn === targetTurn) {
return { text, reason: event.data.reason.kind }
}
}
} catch (error: unknown) {
io.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
}
return { text, reason: 'error' }
}
/**
* Run one headless turn for the configured task and request exit
* (completed → 0, else 1).
* @param ctx - plugin context carrying apiProxy, httpServer, and the launcher's headlessIo.
* @param config - validated {@link Config}.
*/
export function apply(ctx: Context, config: Config): void {
const io = ctx.headlessIo
if (io === undefined) {
throw new Error('headless-runner: the launcher must provide ctx.headlessIo before the tree mounts')
}
// Fire-and-forget by design: the turn outlives plugin activation, and every
// failure path inside ends in io.exit, not a rejection.
void (async () => {
// The headless session is web-observable while it runs (same composition).
io.stderr.write(`dsh: observing at http://127.0.0.1:${String(ctx.httpServer.port)}\n`)
const api = new InProcessApiClient(toFetchHandler(ctx.apiProxy))
const created = await unwrap(await api.sessions.create({}), io)
// 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 done = consumeUntilTurnEnd(frames, created.sessionId, io)
await unwrap(await api.sessions.prompt({
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: config.task }],
}), io)
const outcome = await done
io.stdout.write(outcome.text + '\n')
abort.abort()
io.exit(outcome.reason === 'completed' ? 0 : 1)
})()
}
+30
View File
@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-headless`.
* @module @deepseek-ai/dsh-headless/invariant
*/
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-headless'
/** Cordis companion plugin name. */
export const name = 'headless-invariant'
/** Service required before the companion can register. */
export const inject = ['invariants']
/**
* No runtime invariant: the runner is a one-shot driver over the API carrier
* whose observable contract (final text on stdout, exit code by turn-end
* reason) is process-level and owned by the launcher e2e; it registers
* nothing and holds no mutable relation to audit inside the tree.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))