# Conflicts: # .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml # .agents/notes/implemented/architecture/2026-07-31-goal-owned-durable-events.i18n.yaml # .agents/notes/implemented/feature/2026-07-19-model-facing-goal-tools.i18n.yaml # .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # packages/acp/acp/README.i18n.yaml # packages/acp/acp/src/index.ts # packages/client/connection/src/client/index.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/sessions/conversation.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/runtime/tests/queue-store.spec.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-goal/README.i18n.yaml # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent-loop/tests/agent.spec.ts # packages/core/agent-loop/tests/loop.spec.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/goal/tool-goal/README.i18n.yaml # packages/goal/tool-goal/README.md # packages/goal/tool-goal/README.zh.md # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/events.schema.ts # packages/host/apiproxy/src/api/events.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/tests/api-proxy-commands.spec.ts # packages/host/apiproxy/tests/api-proxy-projections.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/src/index.ts # packages/session-persistence/session-persistence/tests/coordinator-contract.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/subagent/subagent-inprocess/README.i18n.yaml # packages/subagent/subagent-inprocess/README.md # packages/subagent/subagent-inprocess/README.zh.md # packages/subagent/subagent-inprocess/src/index.ts # packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts # packages/support/acp-snapshot/src/harness.ts # packages/ui/tui/tests/tui.spec.ts
122 lines
5.2 KiB
TypeScript
122 lines
5.2 KiB
TypeScript
/**
|
|
* `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'
|
|
|
|
/** 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 (dispose first). */
|
|
async function unwrap<T>(response: RpcResponse<T>, dispose: () => Promise<void>): Promise<T> {
|
|
if (response.result.ok) return response.result.value
|
|
const { code, message } = response.result.error
|
|
process.stderr.write(`dsh: ${code}: ${message}\n`)
|
|
await dispose()
|
|
process.exit(1)
|
|
}
|
|
|
|
/**
|
|
* Consume mux frames until the task turn ends, per the cli-demo runOneShot
|
|
* correlation precedent: the stream opens immediately before the prompt, so
|
|
* its first observed turn/start owns the task. Aggregate text from that turn's
|
|
* assistant/message events (last one wins), then finish on its turn/end.
|
|
*/
|
|
async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>, sessionId: SessionId): Promise<TurnOutcome> {
|
|
let targetTurn: number | undefined
|
|
let text = ''
|
|
try {
|
|
for await (const frame of frames) {
|
|
const payload = frame.payload
|
|
if (payload.type === 'stream/error') {
|
|
process.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') 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) {
|
|
process.stderr.write(`dsh: event stream failed: ${String(error)}\n`)
|
|
}
|
|
return { text, reason: 'error' }
|
|
}
|
|
|
|
/**
|
|
* 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<void> {
|
|
// 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()
|
|
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
|
|
// Signal exits must still dispose the tree: the composition mounts
|
|
// exit-drained plugins (telemetry's queued tail and shutdown marker would
|
|
// otherwise be lost), and Node's default signal exit skips disposal.
|
|
let signalled = false
|
|
const disposeAndExit = (code: number): void => {
|
|
if (signalled) return
|
|
signalled = true
|
|
void dispose().finally(() => { process.exit(code) })
|
|
}
|
|
process.on('SIGTERM', () => { disposeAndExit(143) })
|
|
process.on('SIGINT', () => { disposeAndExit(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({}), dispose)
|
|
|
|
// 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)
|
|
|
|
await unwrap(await api.sessions.prompt({
|
|
sessionId: created.sessionId,
|
|
mode: 'queue',
|
|
content: [{ type: 'text', text: task }],
|
|
}), dispose)
|
|
|
|
const outcome = await done
|
|
process.stdout.write(outcome.text + '\n')
|
|
abort.abort()
|
|
await dispose()
|
|
process.exit(outcome.reason === 'completed' ? 0 : 1)
|
|
}
|