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:
@@ -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
246
apps/cli/cordis.yml
Normal 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'
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
265
apps/cli/src/app-cli-entry.ts
Normal file
265
apps/cli/src/app-cli-entry.ts
Normal 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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) })
|
||||
}
|
||||
|
||||
@@ -14,9 +14,6 @@
|
||||
{
|
||||
"path": "../../packages/host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user