Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	apps/cli/README.md
#	apps/cli/src/web.ts
#	apps/web/tests/smoke-fixture.e2e.ts
#	docs/architecture.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/stdout.expected.jsonl
#	packages/client/connection/src/client/api.ts
#	packages/client/connection/src/client/fixture.ts
#	packages/client/runtime/README.md
#	packages/client/runtime/src/client/index.ts
#	packages/client/runtime/src/client/sessions/conversation.ts
#	packages/client/runtime/src/client/sessions/service.ts
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/ui-conversation/src/client/apply.ts
#	packages/client/ui-conversation/src/client/contract/slots.ts
#	packages/client/ui-conversation/src/client/index.ts
#	packages/client/ui-conversation/src/client/service.ts
#	packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx
#	packages/client/ui-conversation/src/client/skeleton/EmptyState.tsx
#	packages/client/ui-conversation/src/client/skeleton/InputBar.tsx
#	packages/client/ui-conversation/src/client/stores.ts
#	packages/client/ui-conversation/tests/apply-inject.spec.tsx
#	packages/client/ui-conversation/tests/service-orchestration.spec.ts
#	packages/client/ui-conversation/tests/skeleton-branches.spec.tsx
#	packages/client/ui-conversation/tests/skeleton.spec.tsx
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/host/apiproxy/package.json
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/sessions.schema.ts
#	packages/host/runtime/package.json
#	packages/host/runtime/src/boot.ts
#	packages/host/runtime/tests/host-runtime.spec.ts
#	packages/host/runtime/tsconfig.json
#	packages/host/webserver/README.md
#	packages/host/webserver/src/index.ts
#	packages/host/webserver/tests/webserver.spec.ts
#	packages/llm/llm-pi-ai/tests/convert.spec.ts
#	packages/ui/acp/src/codec.ts
#	packages/ui/acp/tests/codec.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang
2026-07-25 22:35:22 +08:00
1264 changed files with 37694 additions and 29428 deletions

View File

@@ -10,7 +10,7 @@ The TUI surface:
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. `dsh web --provider <name> --model <id>` selects the default agent route; a non-`deepseek` provider additionally mounts that pi-ai catalog route with the provider's ambient credentials (e.g. `ANTHROPIC_API_KEY`), which is how image input reaches a visual-capable model, and requires an explicit `--model`. The headless surface retains deterministic fallback titles without making the auxiliary title-model request.
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. `dsh web --provider <name> --model <id>` mounts that pi-ai catalog route with provider-native ambient credentials; the default DeepSeek route remains text-only. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
## Install (developer machine)
@@ -20,4 +20,4 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
```
`pnpm run demo:tui` runs the same entry from the repo root. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.
`pnpm run dsh` runs the same entry from the repo root and forwards arguments directly, for example `pnpm run dsh -p "task"`. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.

246
apps/cli/cordis.yml Normal file
View File

@@ -0,0 +1,246 @@
# dsh web — the full web-shape composition: host runtime (layer 1), the
# transport/service layer (layer 2), and the browser plugin roster (dshClient
# rows the modules node half scans into window.__DSH_BOOT__). Row order
# carries no load semantics (activation is service-availability driven); the
# grouping below is for readers. `--dev` appends the dsh-client-hmr row in
# code (AppCLIEntry) — prod and dev differ by exactly that one row.
# AppCLIEntry patches this tree before boot: profile json + CLI flags +
# distIndex land as config patches over the rows below (yaml = engineering
# defaults, json = user config, user wins per field).
# ── layer 1: runtime ────────────────────────────────────────────────────────
- id: timer
name: '@cordisjs/plugin-timer'
- id: llm
name: '@deepseek-ai/dsh-llm'
- id: attachment-local
name: '@deepseek-ai/dsh-attachment-local'
- id: session
name: '@deepseek-ai/dsh-session'
- id: session-title
name: '@deepseek-ai/dsh-session-title'
config:
fallbackMaxWords: 5
fallbackMaxBytes: 40
maxTitleBytes: 80
# Model-made titles on the first-message cadence (the web sidebar renders
# session/title). Same values as the TUI composition.
- id: session-title-llm
name: '@deepseek-ai/dsh-session-title-first-message-llm'
config:
targetWords: 5
targetCjkCharacters: 10
maxInputBytes: 4096
maxOutputTokens: 64
timeoutMs: 60000
- id: system-prompt
name: '@deepseek-ai/dsh-system-prompt'
config:
persona: ''
- id: tools
name: '@deepseek-ai/dsh-tools'
- id: user-interaction
name: '@deepseek-ai/dsh-user-interaction'
- id: agent
name: '@deepseek-ai/dsh-agent'
- id: tasks
name: '@deepseek-ai/dsh-tasks'
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents: []
# The native DeepSeek adapter; reads the key/base-url the boot's layered
# .env loading (cwd then $DSH_HOME) left in the environment.
- id: llm-deepseek
name: '@deepseek-ai/dsh-llm-deepseek'
config:
apiKey: !!js process.env.DEEPSEEK_API_KEY
baseURL: !!js process.env.DEEPSEEK_BASE_URL
- id: session-persistence-jsonl
name: '@deepseek-ai/dsh-session-persistence-jsonl'
config:
root: './.sessions'
- id: storage
name: '@deepseek-ai/dsh-storage'
- id: storage-json
name: '@deepseek-ai/dsh-storage-json'
config:
root: './.storages'
- id: storage-domain
name: '@deepseek-ai/dsh-storage-domain'
config:
backend: json
- id: workspace
name: '@deepseek-ai/dsh-workspace'
- id: bash-local
name: '@deepseek-ai/dsh-bash-local'
- id: tool-bash
name: '@deepseek-ai/dsh-tool-bash'
- id: tool-todo
name: '@deepseek-ai/dsh-tool-todo'
- id: tool-tasks
name: '@deepseek-ai/dsh-tool-tasks'
# fs cwd stays the package default (process.cwd()) — the same value the
# gateway injects into session.cwd, so paths and sessions agree.
- id: fs-local
name: '@deepseek-ai/dsh-fs-local'
- id: fs-policy
name: '@deepseek-ai/dsh-fs-policy'
- id: tool-fs
name: '@deepseek-ai/dsh-tool-fs'
- id: tool-fs-search
name: '@deepseek-ai/dsh-tool-fs-search'
- id: workspace-context
name: '@deepseek-ai/dsh-workspace-context'
config:
maxBytes: 65536
- id: skill
name: '@deepseek-ai/dsh-skill'
- id: skill-local
name: '@deepseek-ai/dsh-skill-local'
- id: tool-skill
name: '@deepseek-ai/dsh-tool-skill'
# token-meter rejects unknown config keys — keep this row bare.
- id: token-meter
name: '@deepseek-ai/dsh-token-meter'
- id: compact-basic
name: '@deepseek-ai/dsh-compact-basic'
- id: subagent
name: '@deepseek-ai/dsh-subagent'
- id: subagent-spawn
name: '@deepseek-ai/dsh-subagent-spawn'
config:
providerName: spawn
- id: subagent-fork
name: '@deepseek-ai/dsh-subagent-fork'
config:
providerName: fork
- id: tool-subagent
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
toolName: subagent
- id: tool-subagent-fork
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
- id: workflow-workerthread
name: '@deepseek-ai/dsh-workflow-workerthread'
config:
provider: spawn
- id: tool-workflow
name: '@deepseek-ai/dsh-tool-workflow'
- id: timeout-policy
name: '@deepseek-ai/dsh-timeout-policy'
- id: spill-local
name: '@deepseek-ai/dsh-spill-local'
# Omitting maxInlineBytes makes the whole policy a silent no-op — always
# state it explicitly.
- id: spill-policy
name: '@deepseek-ai/dsh-spill-policy'
config:
maxInlineBytes: 50000
# The API gateway: the transport-agnostic dispatch face every client shape
# shares. provider/model are the host default routing — the profile json's
# mapping target (user config overrides these engineering defaults).
- id: api-gateway
name: '@deepseek-ai/dsh-host-apiproxy'
config:
provider: deepseek
model: deepseek-v4-flash
# ── layer 2: transport/service ──────────────────────────────────────────────
# Plain route-registration carrier. distIndex is an assembly fact, not user
# config — AppCLIEntry resolves the frontend dist and patches it in; host and
# port arrive as CLI-flag patches over these defaults.
- id: webserver
name: '@deepseek-ai/dsh-host-webserver'
config:
host: 127.0.0.1
port: 3080
# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ──
# Dual-face: node half scans this very tree for dshClient rows, composes
# window.__DSH_BOOT__, serves /plugins/<id>/client.js; browser half is the
# module table the shell kernel constructs before cordis exists (§4.7 —
# adopted as a plugin entry by the kernel, never fetched).
- id: modules
name: '@deepseek-ai/dsh-client-modules'
# Owns both ends of the web transport: node half binds the gateway to the
# webserver under /api; browser half is the fetch/SSE client.
- id: connection
name: '@deepseek-ai/dsh-client-connection'
- id: client-runtime
name: '@deepseek-ai/dsh-client-runtime'
- id: ui-theme
name: '@deepseek-ai/dsh-client-ui-theme'
- id: i18n
name: '@deepseek-ai/dsh-client-i18n'
- id: ui-layout
name: '@deepseek-ai/dsh-client-ui-layout'
- id: ui-sidebar
name: '@deepseek-ai/dsh-client-ui-sidebar'
- id: ui-conversation
name: '@deepseek-ai/dsh-client-ui-conversation'
- id: ui-workspace
name: '@deepseek-ai/dsh-client-ui-workspace'
- id: ui-question
name: '@deepseek-ai/dsh-client-ui-question'
- id: ui-trajectory
name: '@deepseek-ai/dsh-client-ui-trajectory'

View File

@@ -9,14 +9,23 @@
},
"files": [
"lib/bin.js",
"cordis.yml",
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"@cordisjs/plugin-include": "workspace:*",
"@cordisjs/plugin-loader": "workspace:*",
"@cordisjs/plugin-timer": "workspace:*",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-agent-loop": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-attachment-local": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-i18n": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
@@ -24,13 +33,53 @@
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-compact-basic": "workspace:^",
"@deepseek-ai/dsh-frontend": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-fs-policy": "workspace:^",
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
"@deepseek-ai/dsh-host-runtime": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-storage": "workspace:^",
"@deepseek-ai/dsh-storage-domain": "workspace:^",
"@deepseek-ai/dsh-storage-json": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-fork": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-tasks": "workspace:^",
"@deepseek-ai/dsh-timeout-policy": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-bash": "workspace:^",
"@deepseek-ai/dsh-tool-fs": "workspace:^",
"@deepseek-ai/dsh-tool-fs-search": "workspace:^",
"@deepseek-ai/dsh-tool-skill": "workspace:^",
"@deepseek-ai/dsh-tool-subagent": "workspace:^",
"@deepseek-ai/dsh-tool-tasks": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-tui": "workspace:^",
"cordis": "^4.0.0-rc.7"
"@deepseek-ai/dsh-user-interaction": "workspace:^",
"@deepseek-ai/dsh-workflow-workerthread": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/dsh-workspace-context": "workspace:^",
"cordis": "^4.0.0-rc.7",
"js-yaml": "^4.2.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9"
}
}

View File

@@ -0,0 +1,265 @@
/**
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
* (`dsh web` and `dsh -p` boot the one composition; TUI migrates later).
* Everything here is what must exist before the Loader runs: layered env,
* the patch composition over the shipped cordis.yml (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud triple after the
* tree settles.
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from 'cordis'
import type { FiberState } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import yaml from 'js-yaml'
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
/** Profile file under the invoking directory (read-only this round; never created — see the design's profile ruling). */
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
entryId: string
configKey: string
}
/**
* The static profile→row mapping table. json is user config and wins over the
* yml engineering default per field; a json key absent from this table fails
* loud (a typo silently ignored would read as "setting has no effect").
* Developers extend deployments by adding rows here.
*/
const PROFILE_MAPPINGS: ProfileMapping[] = [
{ jsonPath: 'provider', entryId: 'api-gateway', configKey: 'provider' },
{ jsonPath: 'model', entryId: 'api-gateway', configKey: 'model' },
{ jsonPath: 'persistenceRoot', entryId: 'session-persistence-jsonl', configKey: 'root' },
]
// The include's YAML dialect: `!!js` scalars become expression nodes the
// Loader evaluates at entry activation. The bypass parse below must accept
// them (and passing one through a patch unchanged is legal).
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: data => ({ __jsExpr: String(data) }),
})
const includeYamlSchema = yaml.JSON_SCHEMA.extend(jsExprType)
/**
* Value mirror of cordis's `FiberState` const enum members the sweep needs
* (a const enum has no runtime object to import; same rationale as the
* client-side mirror in dsh-client-web).
*/
const FIBER_ACTIVE = 2 as FiberState.ACTIVE
const FIBER_PENDING = 0 as FiberState.PENDING
/** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */
export interface AppCLIEntryOptions {
/** Absolute path of the shipped cordis.yml. */
configPath: string
/** Whether to append the HMR row (the whole prod/dev difference; web surface only). */
dev: boolean
/** --host when explicitly passed; undefined keeps the yml engineering default. */
host?: string
/**
* Listen port override onto the webserver row. Web passes the --port flag
* value; headless passes 0 (an OS-assigned port, so parallel `dsh -p` runs
* never collide — and the printed URL still opens the live session in a
* browser).
*/
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
/** Host default provider override. Non-DeepSeek routes mount pi-ai with ambient credentials. */
provider?: string
/** Host default model override. */
model?: string
}
/**
* Boot driver for the config-tree dsh surfaces (web and headless share the
* one composition; the surfaces differ only in constructor facts): holds only
* what exists independently of (and prior to) cordis — argv facts, the
* composed patch set, and finally the root ctx.
*/
export class AppCLIEntry {
/** The root context, set by {@link run}. */
ctx!: Context
private patches: PatchOptions[] = []
private piAiProvider: string | undefined
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: layered env → patch composition → Loader include
* boot (dev row before await) → fail-loud triple.
* @returns the settled root context and the listening port.
*/
async run(): Promise<{ ctx: Context; port: number }> {
this.loadEnvLayers()
this.composePatches()
await this.bootTree()
this.assertBoot()
const port = this.ctx.get('httpServer')?.port
/* v8 ignore next -- the sweep above guarantees an ACTIVE webserver row */
if (port === undefined) throw new Error('dsh: httpServer service missing after settled boot')
return { ctx: this.ctx, port }
}
/** Layered .env: ambient > cwd (bin already loaded) > $DSH_HOME (loadEnvFile never overrides). */
private loadEnvLayers(): void {
loadEnv('dsh', resolveDshHome())
}
/**
* Compose the patch set from the three non-yml config sources: profile
* json (user config), CLI flags, and the resolved frontend dist. Patches
* replace a row's config wholesale, so each patched row's yml static
* values are re-read here (bypass parse) and merged under the overrides.
*/
private composePatches(): void {
const rows = this.parseYmlRows()
const overrides = new Map<string, Record<string, unknown>>()
const put = (entryId: string, key: string, value: unknown): void => {
const bag = overrides.get(entryId) ?? {}
bag[key] = value
overrides.set(entryId, bag)
}
// Source 1: profile json (missing file = empty; unmapped key = loud).
const profile = this.readProfile()
for (const [key, value] of Object.entries(profile)) {
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
if (mapping === undefined) {
throw new Error(`dsh: profile key "${key}" has no mapping (known: ${PROFILE_MAPPINGS.map(m => m.jsonPath).join(', ')})`)
}
put(mapping.entryId, mapping.configKey, value)
}
// Source 2: CLI flags (field set disjoint from the json mappings).
if (this.options.host !== undefined) put('webserver', 'host', this.options.host)
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
if (this.options.provider !== undefined) put('api-gateway', 'provider', this.options.provider)
if (this.options.model !== undefined) put('api-gateway', 'model', this.options.model)
const gatewayConfig = rows.get('api-gateway')?.config as Record<string, unknown> | undefined
const provider = this.options.provider ?? profile.provider ?? gatewayConfig?.provider
const model = this.options.model ?? profile.model ?? gatewayConfig?.model
if (typeof provider !== 'string' || provider === '') {
throw new Error('dsh: api-gateway provider must be a non-empty string')
}
if (provider !== 'deepseek' && (typeof model !== 'string' || model === '' || model === 'deepseek-v4-flash')) {
throw new Error(`dsh: provider ${provider} requires an explicit model`)
}
this.piAiProvider = provider === 'deepseek' ? undefined : provider
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
this.patches = [...overrides.entries()].map(([id, bag]) => {
const yml = rows.get(id)
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
return { id, config: { ...(yml.config ?? {}) as Record<string, unknown>, ...bag } }
})
}
/** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
private async bootTree(): Promise<void> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await ctx.loader.create({
name: 'cordis:include',
config: {
path: pathToFileURL(resolve(this.options.configPath)).href,
...this.patches.length > 0 ? { patches: this.patches } : {},
},
})
if (this.piAiProvider !== undefined) {
await ctx.loader.create({
name: '@deepseek-ai/dsh-llm-pi-ai',
config: { providers: [{ provider: this.piAiProvider }] },
})
}
if (this.options.dev) {
await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
}
this.ctx = ctx
await ctx.loader.await()
}
/**
* Fail-loud triple: assertEntriesLoaded catches import failures,
* installFailLoud catches late apply rejections, and the all-ACTIVE sweep
* below catches PENDING fibers (cordis inject waiting has no timeout).
*/
private assertBoot(): void {
installFailLoud('dsh')
assertEntriesLoaded(this.ctx, 'dsh')
const failures: string[] = []
for (const entry of this.ctx.loader.entries()) {
if (entry.fiber === undefined || entry.disabled) continue
const state = entry.fiber.state
if (state === FIBER_ACTIVE) continue
if (state === FIBER_PENDING) {
const missing = Object.keys(entry.fiber.inject).filter(service => this.ctx.get(service) === undefined)
failures.push(`${entry.options.name}: pending (waiting for service${missing.length === 1 ? '' : 's'}: ${missing.join(', ') || 'unknown'})`)
} else {
failures.push(`${entry.options.name}: fiber state ${String(state)}`)
}
}
if (failures.length > 0) {
throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
}
/** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */
private parseYmlRows(): Map<string, { config?: unknown }> {
const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`)
const rows = new Map<string, { config?: unknown }>()
for (const row of doc as { id?: string; config?: unknown }[]) {
if (typeof row.id === 'string') rows.set(row.id, row)
}
return rows
}
/** Profile json under cwd; read-only — never created here, absent = no user config. */
private readProfile(): Record<string, unknown> {
let raw: string
try {
raw = readFileSync(join(process.cwd(), PROFILE_DIR, PROFILE_FILE), 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {}
throw error
}
const parsed: unknown = JSON.parse(raw)
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`dsh: ${PROFILE_DIR}/${PROFILE_FILE} must hold a JSON object`)
}
return parsed as Record<string, unknown>
}
/** Dist location is workspace knowledge of this app: resolved through the frontend package exports, not configured. */
private resolveDistIndex(): string {
const require = createRequire(import.meta.url)
try {
return require.resolve('@deepseek-ai/dsh-frontend/dist/index.html')
} catch {
throw new Error('dsh: frontend dist not built; run pnpm --filter @deepseek-ai/dsh-frontend build first')
}
}
}

View File

@@ -1,18 +1,20 @@
/**
* `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).
* `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
* boots the same cordis.yml 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 { parseArgs } from 'node:util'
import { startHost } from '@deepseek-ai/dsh-host-runtime'
import { InProcessApiClient } from '@deepseek-ai/dsh-host-apiproxy'
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 {
@@ -78,15 +80,18 @@ export async function runHeadless(argv: string[]): Promise<void> {
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: false,
},
const entry = new AppCLIEntry({
configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)),
dev: false,
port: 0,
})
const api = new InProcessApiClient(host.handler)
const { ctx, port } = await entry.run()
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
// 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({}), () => host.dispose())
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
@@ -99,11 +104,11 @@ export async function runHeadless(argv: string[]): Promise<void> {
sessionId: created.sessionId,
mode: 'queue',
content: [{ type: 'text', text: task }],
}), () => host.dispose())
}), dispose)
const outcome = await done
process.stdout.write(outcome.text + '\n')
abort.abort()
await host.dispose()
await dispose()
process.exit(outcome.reason === 'completed' ? 0 : 1)
}

View File

@@ -1,203 +1,72 @@
/**
* `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).
* `dsh web` — thin bin over the config-tree boot: parse argv, run
* AppCLIEntry, print the URL line, wire signals. All composition lives in
* cordis.yml; all boot glue lives in AppCLIEntry.
*/
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'
import { fileURLToPath } from 'node:url'
import { AppCLIEntry } from './app-cli-entry.ts'
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
const REQUEST_ENVELOPE_HEADROOM_BYTES = 1024 * 1024
// --- Client composition (composition decisions live in the composing app) ---
// The composition layer owns one decision: which plugin packages mount (the
// roster). Dependency edges and the boot prefetch tier live in each package's
// dshClient declaration.
/**
* Dev-only plugin: the client HMR driver. Whether it composes in is a
* deployment decision — the dev graph includes its row, the prod graph does
* not mount it at all.
*/
const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr'
/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */
const CLIENT_BUNDLE_POLL_MS = 500
/** The client plugin roster (flat; per-row boot behavior comes from manifests). */
const CLIENT_PACKAGES = [
'@deepseek-ai/dsh-client-connection',
'@deepseek-ai/dsh-client-runtime',
'@deepseek-ai/dsh-client-ui-theme',
'@deepseek-ai/dsh-client-i18n',
'@deepseek-ai/dsh-client-ui-layout',
'@deepseek-ai/dsh-client-ui-sidebar',
'@deepseek-ai/dsh-client-ui-conversation',
'@deepseek-ai/dsh-client-ui-question',
'@deepseek-ai/dsh-client-ui-trajectory',
] as const
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
export async function runWeb(argv: string[]): Promise<void> {
const { values } = parseArgs({
args: argv,
options: {
host: { type: 'string', default: LOOPBACK_HOST },
port: { type: 'string', default: '3080' },
'max-request-body-bytes': { type: 'string' },
host: { type: 'string' },
port: { type: 'string' },
dev: { type: 'boolean', default: false },
'workspace-root': { type: 'string' },
provider: { type: 'string' },
model: { type: 'string' },
dev: { type: 'boolean', default: false },
},
allowPositionals: false,
})
if (values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
if (values.host !== undefined && values.host !== LOOPBACK_HOST && values.host !== ALL_INTERFACES_HOST) {
process.stderr.write(
`dsh web: invalid --host ${values.host}; expected ${LOOPBACK_HOST} or ${ALL_INTERFACES_HOST}\n`,
)
process.exit(1)
}
const hostAddress = values.host
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)
}
const configuredMaxRequestBodyBytes = values['max-request-body-bytes'] === undefined
? undefined
: Number(values['max-request-body-bytes'])
if (configuredMaxRequestBodyBytes !== undefined
&& (!Number.isInteger(configuredMaxRequestBodyBytes)
|| configuredMaxRequestBodyBytes < 1)) {
process.stderr.write(
`dsh web: invalid --max-request-body-bytes ${values['max-request-body-bytes']}\n`,
)
process.exit(1)
}
// Default routing: --provider deepseek (implicit) keeps the DeepSeek-only
// assembly; any other --provider additionally mounts that pi-ai catalog
// route (credentials via the provider's ambient discovery, e.g.
// ANTHROPIC_API_KEY) so visual-capable models are reachable from the shipped
// Web app. A non-default provider requires an explicit --model — this shell
// has no evidence for inventing another provider's default.
const provider = values.provider ?? 'deepseek'
if (provider !== 'deepseek' && values.model === undefined) {
process.stderr.write(`dsh web: --provider ${provider} requires an explicit --model\n`)
process.exit(1)
}
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught
// by design); an unknown --provider fails the pi-ai catalog check the same way.
const host = await startHost({
boot: {
persistenceRoot: './.sessions',
workspaceContext: { maxBytes: 65_536 },
sessionTitleLlm: true,
...provider === 'deepseek' ? {} : { piAiProviders: [{ provider }] },
...values.provider === undefined ? {} : { provider: values.provider },
...values.model === undefined ? {} : { model: values.model },
},
})
const attachments = host.ctx.get('attachments')
if (attachments === undefined) throw new Error('dsh web: attachment service unavailable')
const maxRequestBodyBytes = configuredMaxRequestBodyBytes
?? Math.ceil(attachments.imageLimits.maxMessageImageBytes * 4 / 3)
+ REQUEST_ENVELOPE_HEADROOM_BYTES
// Client plugin chain: in-memory Loader tree over the composed roster, then
// the registry that feeds the __DSH_BOOT__ entry graph and
// /plugins/<id>/client.js. All row content comes from dshClient discovery
// over the mounted roster (dev adds the HMR driver row and turns on the
// bundle watch that drives rebuilt frames).
const roster = [...CLIENT_PACKAGES, ...values.dev ? [CLIENT_HMR_ID] : []]
const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url)
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`) },
...values.dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {},
})
if (values.dev) {
// Dev visibility (the registry is a library and never prints): list what
// the bundle watch covers, then log every observed rebuild. This is a
// second onRebuilt subscription — the SSE relay inside the webserver is
// unaffected (multicast).
const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev]))
const bundlePaths = [...revs.keys()]
.map(id => webPlugins.clientPath(id))
.filter((path): path is string => path !== undefined)
console.log(
`dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`,
)
webPlugins.onRebuilt((id, rev) => {
console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`)
revs.set(id, rev)
})
}
// 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 port: number | undefined
if (values.port !== undefined) {
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)
}
}
let server: Awaited<ReturnType<typeof startWebServer>>
try {
server = await startWebServer(
{
host: hostAddress,
port,
distIndex,
apiHandler: host.handler,
maxRequestBodyBytes,
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)
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
dev: values.dev,
...values.host !== undefined ? { host: values.host } : {},
...port !== undefined ? { port } : {},
...values['workspace-root'] !== undefined ? { workspaceRoot: values['workspace-root'] } : {},
...values.provider !== undefined ? { provider: values.provider } : {},
...values.model !== undefined ? { model: values.model } : {},
})
const { ctx, port: boundPort } = await entry.run()
let exiting = false
const shutdown = (code: number): void => {
if (exiting) return
exiting = true
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lan = hostAddress === ALL_INTERFACES_HOST
const lanCandidate = values.host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined
const localUrl = `http://${LOOPBACK_HOST}:${server.port}`
console.log(`dsh web: ${localUrl}${lan === undefined ? '' : ` (LAN: http://${lan.address}:${server.port})`}`)
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
process.on('SIGTERM', () => { void shutdown(0) })
process.on('SIGINT', () => { void shutdown(130) })
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
}

View File

@@ -14,9 +14,6 @@
{
"path": "../../packages/host/apiproxy"
},
{
"path": "../../packages/host/runtime"
},
{
"path": "../../packages/host/webserver"
},