Merge latest master into tui-staging-merge-1

This commit is contained in:
Tianyi Cui
2026-07-22 20:45:24 +08:00
392 changed files with 33687 additions and 446 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: the interactive TUI coding agent, booting the shipped default config with the personal overlay from ~/.dsh",
"description": "dsh CLI: interactive TUI, headless task, and browser UI surfaces",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -14,6 +14,11 @@
"license": "BSD-3-Clause",
"dependencies": {
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^"
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-runtime": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^"
}
}

View File

@@ -1,23 +1,25 @@
#!/usr/bin/env node
/**
* dsh — command-line entry. Coarse dispatch only; each surface module owns its
* own argument handling. `web` and `-p`/`--prompt` are reserved for the
* browser GUI and headless surfaces (PR #443) so that dispatch merges as a
* union; everything else is the interactive TUI, the default surface.
* argument handling. Dynamic imports keep unrelated surfaces out of each
* dispatch path; everything except `web` and headless prompts opens the TUI.
* @module @deepseek-ai/dsh/bin
*/
/* v8 ignore file -- thin self-executing dispatch; the tui-agent PTY smoke
exercises the TUI path end to end */
/* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */
import { loadEnv } from '@deepseek-ai/dsh-app-boot'
import { runTui } from './tui.ts'
loadEnv('dsh')
const argv = process.argv.slice(2)
if (argv[0] === 'web' || argv.includes('-p') || argv.includes('--prompt')) {
process.stderr.write('dsh: the web and headless surfaces are not on this branch (PR #443); run the TUI: dsh [config.yml]\n')
process.exit(1)
if (argv[0] === 'web') {
const { runWeb } = await import('./web.ts')
await runWeb(argv.slice(1))
} else if (argv.includes('-p') || argv.includes('--prompt')) {
const { runHeadless } = await import('./headless.ts')
await runHeadless(argv)
} else {
const { runTui } = await import('./tui.ts')
await runTui(argv)
}
await runTui(argv)

104
apps/cli/src/headless.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* `dsh -p "task"` — the headless assembly: startHost + in-process isomorphic
* injection (InProcessApiClient over the host handler, so the full carrier
* chain — wire serialization, zod, SSE framing — really runs; this is the
* protocol's second real consumer). No HTTP server, no port, no dist
* resolution. Runs one task turn, prints the final assistant text, exits
* (completed → 0, else 1).
*/
import { parseArgs } from 'node:util'
import { startHost } from '@deepseek-ai/dsh-host-runtime'
import { InProcessApiClient } 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'
/** 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: 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): 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' && event.data.trigger.kind === 'message') targetTurn = event.data.turn
continue
}
if (event.type === 'assistant/message' && event.data.turn === targetTurn) {
const joined = event.data.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' }
}
export async function runHeadless(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: { prompt: { type: 'string', short: 'p' } },
allowPositionals: false,
})
const task = values.prompt
if (task === undefined || task === '') {
process.stderr.write('usage: dsh -p "task"\n')
process.exit(1)
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({ boot: { persistenceRoot: './.sessions' } })
const api = new InProcessApiClient(host.handler)
const created = await unwrap(await api.sessions.create({}), () => host.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 }],
}), () => host.dispose())
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await host.dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
}

89
apps/cli/src/web.ts Normal file
View File

@@ -0,0 +1,89 @@
/**
* `dsh web` — the web-shape assembly: startHost + dist resolution +
* startWebServer + the URL line + signal wiring. Mixing host and carrier
* concerns is this app module's job (packages stay single-sided).
*/
import { parseArgs } from 'node:util'
import { networkInterfaces } from 'node:os'
import { createRequire } from 'node:module'
import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
export async function runWeb(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: { port: { type: 'string', default: '3080' } },
allowPositionals: false,
})
const port = Number(values.port)
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
process.stderr.write(`dsh web: invalid --port ${values.port}\n`)
process.exit(1)
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({ boot: { persistenceRoot: './.sessions' } })
// Web UI plugin chain: in-memory Loader tree over the eight UI packages,
// then the registry that feeds __DSH_BOOT__ and /plugins/<id>/client.js.
const mounted = await mountWebPlugins(host.ctx)
const webPlugins = createHostWebPluginRegistry({
ctx: host.ctx,
loader: mounted.loader,
resolvePkgJson: mounted.resolvePkgJson,
onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
})
// Published so the webserver invariant companion can audit manifest/bundle
// consistency; nothing else reads this key.
host.ctx.reflect.provide('webPlugins', webPlugins)
// Dist location is workspace knowledge of this app: resolved through
// @deepseek-ai/dsh-frontend's package exports, not configured.
const require = createRequire(import.meta.url)
let distIndex: string
try {
distIndex = require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
process.stderr.write('dsh web: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first\n')
await host.dispose()
process.exit(1)
}
let exiting = false
async function shutdown(code: number): Promise<void> {
if (exiting) return
exiting = true
try {
await server.close()
await host.dispose()
} finally {
process.exit(code)
}
}
let server: Awaited<ReturnType<typeof startWebServer>>
try {
server = await startWebServer(
{ port, distIndex, apiHandler: host.handler, webPlugins },
(err: Error) => {
process.stderr.write(`dsh web: ${String(err)}\n`)
void shutdown(1)
},
)
} catch (error: unknown) {
// listen failed (EADDRINUSE…): no server to close, dispose the host directly.
process.stderr.write(`dsh web: ${String(error)}\n`)
await host.dispose()
process.exit(1)
}
// The server binds 0.0.0.0 (remote-container + LAN-browser is the primary scenario);
// print the LAN address alongside loopback so the printed URL is copy-usable from outside.
const lan = Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
console.log(`dsh web: http://127.0.0.1:${server.port}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
process.on('SIGTERM', () => { void shutdown(0) })
process.on('SIGINT', () => { void shutdown(130) })
}

View File

@@ -8,11 +8,12 @@
"src"
],
"references": [
{
"path": "../../packages/ui/app-boot"
},
{
"path": "../../packages/util/paths"
}
{ "path": "../../vendor/cordis" },
{ "path": "../../packages/host/apiproxy" },
{ "path": "../../packages/host/runtime" },
{ "path": "../../packages/host/webserver" },
{ "path": "../../packages/core/session" },
{ "path": "../../packages/ui/app-boot" },
{ "path": "../../packages/util/paths" }
]
}