Merge origin/master into fix/web-agent-runtime-context

This commit is contained in:
Tianyi Cui
2026-08-01 22:04:10 +08:00
2914 changed files with 226213 additions and 29019 deletions

View File

@@ -1,23 +1,22 @@
/**
* 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.
* for the Web/headless surface.
* Everything here is what must exist before the Loader runs: the patch
* composition over the shipped base and surface overlay (profile json + CLI
* flags + the resolved frontend dist), and the fail-loud activation audit after the tree
* settles. The environment is what the bin already loaded (ambient plus the
* invoking directory's `.env`); `$DSH_HOME/.env` belongs to the credential
* provider and is never hoisted here.
*/
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { networkInterfaces } from 'node:os'
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 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'
import { boot, installFailLoud, loadOverlayPatches, loadPersonalPatches } from '@deepseek-ai/dsh-app-boot'
// Empty type import carries the httpServer Context merge for the port read below.
import type {} from '@deepseek-ai/dsh-host-webserver'
@@ -25,6 +24,76 @@ import type {} from '@deepseek-ai/dsh-host-webserver'
const PROFILE_DIR = '.dsh-tmp-profile'
const PROFILE_FILE = 'config.json'
/** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets (mounted in web.cordis.yml). */
const TELEMETRY_ROW_ID = 'telemetry-otel'
/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation here and the printed LAN URL in web.ts. */
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Non-internal IPv4 interface addresses of this machine — the IP-literal
* authorities an all-interfaces bind is reachable by on the LAN.
* @returns the addresses in interface order (possibly empty).
*/
function lanIPv4Addresses(): string[] {
return Object.values(networkInterfaces()).flat()
.filter((iface): iface is NonNullable<typeof iface> => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
.map(iface => iface.address)
}
/**
* One LAN-trust resolution for one invocation, sampled exactly once: the
* machine's LAN IP literals when the effective bind is all-interfaces, and
* the `trustedHosts` value built from them plus the explicit extras. The
* single sample is deliberate — display must advertise only addresses the
* fence was configured with, so both read this snapshot. Derived entries are
* port-less IP literals: DNS rebinding needs an attacker-controlled name, so
* an IP-literal Host is safe on any port, and the bound port may be
* OS-assigned, unknowable pre-boot.
* @param bindHost - the effective webserver bind host (CLI flag, else the yml default).
* @param extra - `--trusted-host` values, in argv order.
* @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty).
*/
export function resolveLanTrust(
bindHost: string | undefined,
extra: readonly string[],
): { lanAddresses: string[]; trustedHosts: string[] } {
const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : []
return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] }
}
/**
* Resolve the telemetry opt-out switch into its boot patch. ANY non-empty
* value (including `'0'`/`'false'`) disables: a privacy switch prefers
* off-by-mistake over on-by-mistake. Throws when the switch is set but the
* row is absent — a silently no-op "disabled" privacy switch would keep
* exporting while the user believes it is off.
* @param disabledEnv - the raw `DSH_TELEMETRY_DISABLED` value (`undefined` when unset).
* @param hasRow - whether the composition carries the {@link TELEMETRY_ROW_ID} row.
* @returns the disable patch, or `undefined` when telemetry stays enabled.
*/
export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: boolean): PatchOptions | undefined {
if ((disabledEnv ?? '') === '') return undefined
if (!hasRow) {
throw new Error(`dsh: DSH_TELEMETRY_DISABLED is set but row "${TELEMETRY_ROW_ID}" is not in this composition`)
}
return { id: TELEMETRY_ROW_ID, disabled: true }
}
/**
* Whether a config file carries the telemetry row, parsed under the same
* `!!js`-tolerant dialect the boot uses — the `hasRow` input for launchers
* that compose their patch lists outside {@link AppCLIEntry} (the TUI).
* @param file - absolute path of the config or overlay file.
* @returns true when a top-level (or inserted) row has the telemetry id.
*/
export function configHasTelemetryRow(file: string): boolean {
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
return (doc as { id?: string; insert?: { id?: string }[] }[]).some(row =>
row.id === TELEMETRY_ROW_ID || (row.insert ?? []).some(inserted => inserted.id === TELEMETRY_ROW_ID))
}
/** One profile-json key mapped onto a yml row's config field. */
interface ProfileMapping {
jsonPath: string
@@ -54,18 +123,23 @@ const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
})
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. */
/** Absolute path of the shared base config the Loader includes. */
configPath: string
/**
* Absolute path of this surface's overlay: a patch list applied over
* {@link configPath} before this entry's own profile/flag patches. Its rows
* are also merge inputs, so a flag override preserves the overlay's other
* fields on the same row.
*/
overlayPath: string
/**
* Optional explicit overlay applied after {@link overlayPath} and before
* this entry's own profile/flag patches. When absent, the personal
* `$DSH_HOME/config.yaml` overlay is applied instead.
*/
extraOverlayPath?: 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. */
@@ -79,6 +153,8 @@ export interface AppCLIEntryOptions {
port?: number
/** Parent directory for name-created Workspaces; undefined uses the gateway's cwd fallback. */
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`), appended to the derived LAN IP literals. */
trustedHosts?: string[]
}
/**
@@ -91,17 +167,24 @@ export class AppCLIEntry {
/** The root context, set by {@link run}. */
ctx!: Context
/**
* LAN IPv4 addresses sampled once at patch composition — the exact snapshot
* the /api trust fence was configured with. Display reads this instead of
* re-sampling, so the advertised LAN URL can never name an address the
* fence rejects. Empty unless the effective bind is all-interfaces.
*/
lanAddresses: readonly string[] = []
private patches: PatchOptions[] = []
constructor(private readonly options: AppCLIEntryOptions) {}
/**
* Run the boot chain: layered env → patch composition → Loader include
* boot (dev row before await) → fail-loud triple.
* Run the boot chain: 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()
@@ -111,16 +194,9 @@ export class AppCLIEntry {
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 non-yml config sources: computed
* engineering defaults (the global session root), profile json (user
* config, overriding those defaults), CLI flags, and the resolved frontend
* dist. Patches replace a row's config wholesale, so each patched row's yml
* Compose the patch set from profile json, 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 {
@@ -132,12 +208,6 @@ export class AppCLIEntry {
overrides.set(entryId, bag)
}
// Source 0: computed engineering defaults. The session store defaults to
// a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history
// is shared across every cwd, not a project-local ./.sessions. The profile
// (Source 1) overwrites this same field via last-write-wins in put().
put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions'))
// Source 1: profile json (missing file = empty; unmapped key = loud).
for (const [key, value] of Object.entries(this.readProfile())) {
const mapping = PROFILE_MAPPINGS.find(m => m.jsonPath === key)
@@ -152,6 +222,13 @@ export class AppCLIEntry {
if (this.options.port !== undefined) put('webserver', 'port', this.options.port)
if (this.options.workspaceRoot !== undefined) put('api-gateway', 'workspaceRoot', this.options.workspaceRoot)
// Source 2b: authorities for the /api browser-trust fence (rationale on
// resolveLanTrust).
const ymlHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host
const { lanAddresses, trustedHosts } = resolveLanTrust(this.options.host ?? ymlHost, this.options.trustedHosts ?? [])
this.lanAddresses = lanAddresses
if (trustedHosts.length > 0) put('connection', 'trustedHosts', trustedHosts)
// Source 3: the frontend dist — an assembly fact of this app, never yml
// user config. Workspace knowledge stays here.
put('webserver', 'distIndex', this.resolveDistIndex())
@@ -161,62 +238,68 @@ export class AppCLIEntry {
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 } }
})
// Telemetry opt-out: a row can only be turned off at the patch layer
// (config cannot disable an entry), and the switch must hold BEFORE the
// plugin constructs — its exporter.url validation is load-time fail-loud.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID))
if (telemetryPatch !== undefined) this.patches.push(telemetryPatch)
}
/** Loader include boot; the dev HMR row mounts before await so the fail-loud triple covers it. */
/** Shared Loader boot; the dev HMR row mounts before await so the activation audit 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 } : {},
},
// One include of the shared base with every overlay as a sibling patch
// list: patches never cross an include boundary, so nesting them would
// silently stop reaching base rows. The surface overlay applies first, then
// this entry's profile-json and CLI-flag patches, which therefore win.
const patches = [
...loadOverlayPatches('dsh', this.options.overlayPath),
...this.options.extraOverlayPath === undefined
? loadPersonalPatches('dsh') ?? []
: loadOverlayPatches('dsh', this.options.extraOverlayPath),
...this.patches,
]
this.ctx = await boot('dsh', resolve(this.options.configPath), patches, async (ctx) => {
if (this.options.dev) await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
})
if (this.options.dev) {
await ctx.loader.create({ name: '@deepseek-ai/dsh-client-hmr' })
}
this.ctx = ctx
await ctx.loader.await()
}
/** Install the diagnostic for plugin rejections that happen after settled boot. */
private assertBoot(): void {
installFailLoud('dsh')
}
/**
* 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).
* Bypass parse of the base and this surface's overlay (id → row) for
* patch-merge inputs; the Loader still reads both files itself. The overlay
* wins per row, matching the order its patches are applied in, and its
* `insert` rows are indexed too because a flag may target one of them.
*/
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)}`)
private parseYmlRows(): Map<string, { config?: unknown }> {
const rows = new Map<string, { config?: unknown }>()
const files = [this.options.configPath, this.options.overlayPath]
if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath)
for (const file of files) {
for (const row of this.parseRowList(file)) {
if (typeof row.id === 'string') rows.set(row.id, row)
for (const inserted of row.insert ?? []) {
if (typeof inserted.id === 'string') rows.set(inserted.id, inserted)
}
}
}
if (failures.length > 0) {
throw new Error(`dsh: ${String(failures.length)} entr${failures.length === 1 ? 'y' : 'ies'} did not activate\n${failures.join('\n')}`)
}
return rows
}
/** 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
/**
* Parse one entry or patch list, rejecting anything that is not a top-level
* array so a malformed file fails here rather than at row lookup.
* @param file - absolute path of the config or overlay file.
* @returns the parsed top-level entries.
*/
private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] {
const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema })
if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`)
return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[]
}
/** Profile json under cwd; read-only — never created here, absent = no user config. */
@@ -241,7 +324,7 @@ export class AppCLIEntry {
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')
throw new Error('dsh: frontend dist not built; run pnpm run build from the repository root first')
}
}
}

View File

@@ -2,55 +2,135 @@
* Commander adapter for the `dsh` command-line entry: the one place argv is
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
* and dynamic-imports that mode's module. One program: the default (no
* subcommand) is the TUI/headless surface with option-only flags; `web` is a
* real subcommand. Commander owns `--help`/`--version` and parse errors — it
* prints and exits at the point of failure (a domain failure routes through
* subcommand) is the TUI/headless surface with option-only flags;
* `meta`, `upgrade`, and `web` are real subcommands; the experimental ones
* (`meta`, `upgrade`) run only under the `--experimental` flag or
* `DSH_EXPERIMENTAL=1`. Commander owns
* `--help`/`--version` and parse
* errors — it prints and exits at the point of failure (a domain failure routes through
* `command.error`), so this returns only a resolved mode.
* @module @deepseek-ai/dsh/args
*/
import { Command, CommanderError } from 'commander'
/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume <id>` rehydrates a session. */
/**
* Interactive TUI: the default mode. `--config` applies an overlay over the
* shipped composition in place of the personal one, `--config-replace` boots a
* file as the whole tree instead, and `--resume <id>` rehydrates a session.
*/
interface TuiInvocation {
mode: 'tui'
config?: string
configReplace?: string
resume?: string
}
/**
* Print the composed config tree and exit, without booting: `--dump-config`
* composes the shipped base, the surface overlay, and the `--config` or
* personal overlay — exactly the layers that surface would boot;
* `--dump-default-config` stops at the surface overlay (the shipped tree, no
* user layer).
*/
interface DumpConfigInvocation {
mode: 'dump-config'
surface: 'tui' | 'web'
/** Omit the `--config`/personal layer and print only the shipped composition. */
defaultOnly: boolean
/** The `--config` overlay to compose instead of the personal one. */
config?: string
}
/** Headless one-shot: `dsh -p "task"`. */
interface HeadlessInvocation {
mode: 'headless'
prompt: string
}
/** Interactive fresh TUI over this harness checkout; accepts no default-surface options, only the experimental gate. */
interface MetaInvocation {
mode: 'meta'
}
/**
* Guided fresh-session entry: `dsh upgrade` seeds the first turn
* with the `dsh-upgrade` skill. It always mints a
* fresh session in the invoking directory and takes no options beyond the
* experimental gate — `--resume`, `--config`, and `-p` are rejected as
* mistyped, so there is nothing to carry.
*/
interface SkillSessionInvocation {
mode: 'upgrade'
}
/**
* Browser UI: `dsh web`. `host`/`port` are present only when the flag was
* passed — pass-through overrides with no CLI default and no CLI validation:
* the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal,
* `port` a natural ≤ 65535) is the single source of both the default (the
* shipped `cordis.yml` value stands when a flag is absent) and validity (a bad
* shipped Web overlay value stands when a flag is absent) and validity (a bad
* value fails loud at boot). `port` is `Number`-coerced only because the schema
* wants a number, not a string. `dev` mounts the client HMR driver;
* `workspaceRoot` is the parent directory for name-created workspaces.
*/
interface WebInvocation {
mode: 'web'
/** Overlay of loader patches applied over the shipped web composition. */
config?: string
host?: string
port?: number
dev: boolean
workspaceRoot?: string
/** Extra authorities for the /api browser-trust fence (`host` or `host:port`); LAN IP literals are derived, not listed here. */
trustedHosts?: string[]
}
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
export type DshInvocation =
| TuiInvocation
| DumpConfigInvocation
| HeadlessInvocation
| MetaInvocation
| SkillSessionInvocation
| WebInvocation
/** Raw web-subcommand options straight from Commander. */
interface WebOptions {
config?: string
host?: string
port?: string
dev?: boolean
workspaceRoot?: string
trustedHost?: string[]
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}
/**
* Resolve the two dump flags for one surface, or return `undefined` when
* neither was passed. Both flags together are contradictory (one includes the
* user layer, the other excludes it) and fail loud through `error`.
*/
function resolveDump(
surface: 'tui' | 'web',
options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
error: (message: string) => never,
): DumpConfigInvocation | undefined {
if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) return undefined
if (options.dumpConfig === true && options.dumpDefaultConfig === true) {
error('error: --dump-config and --dump-default-config are mutually exclusive')
}
const defaultOnly = options.dumpDefaultConfig === true
if (defaultOnly && options.config !== undefined) {
error('error: --dump-default-config prints the shipped tree and takes no --config')
}
return {
mode: 'dump-config',
surface,
defaultOnly,
...options.config !== undefined && { config: options.config },
}
}
/**
@@ -62,10 +142,12 @@ interface WebOptions {
function resolveWeb(options: WebOptions): WebInvocation {
return {
mode: 'web',
...options.config !== undefined && { config: options.config },
...options.host !== undefined && { host: options.host },
...options.port !== undefined && { port: Number(options.port) },
dev: options.dev === true,
...options.workspaceRoot !== undefined && { workspaceRoot: options.workspaceRoot },
...options.trustedHost !== undefined && { trustedHosts: options.trustedHost },
}
}
@@ -75,28 +157,62 @@ function resolveWeb(options: WebOptions): WebInvocation {
* TUI/headless surface; `web` is a subcommand.
* @param argv - the arguments after the node binary and script (`process.argv.slice(2)`).
* @param version - the version string `--version` prints; read from this app's package.json.
* @param experimentalEnv - whether the environment opts into experimental
* subcommands (`DSH_EXPERIMENTAL=1`); the caller reads the process boundary.
* @returns the resolved invocation (only reached on a valid, non-help invocation).
*/
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation {
let resolved: DshInvocation | undefined
const program = new Command()
.name('dsh')
.version(version, '-V, --version', 'output the version number')
.description('dsh: interactive TUI (default), headless task, and browser UI')
.description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.')
// The default surface takes no positional task, so `dsh "task"` fails
// commander's arity check with no hint; these examples are where a first
// reader learns the entry points and that a one-shot task rides `-p`.
.addHelpText('after', `
Examples:
dsh start an interactive session in this directory
dsh -p "run the tests" answer one task, print the result, and exit
dsh --resume <id> continue a past session
`)
.exitOverride()
// Stop parent options at a subcommand boundary so `web --config` belongs to
// Web while `--config ... web` remains a leaked default-surface option.
.enablePositionalOptions()
// Default surface: option-only (no positional), so `web` can be a real
// subcommand without a positional collision.
.option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
.option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
.option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
.action((options: { config?: string; prompt?: string; resume?: string }) => {
.option('-p, --prompt <task>', 'answer this task without the interactive UI, then exit')
.option('--resume <id>', 'continue a past session by id')
.option('--config <path>', 'apply this overlay of loader patches instead of the personal one')
.option('--config-replace <path>', 'boot this file as the entire tree, ignoring the shipped and personal configuration')
.option('--dump-config', 'print the composed config tree (base + surface + --config/personal overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + surface overlay, no user layer) and exit')
.action((options: {
config?: string
configReplace?: string
prompt?: string
resume?: string
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}) => {
const dump = resolveDump('tui', options, message => program.error(message))
if (dump !== undefined) {
// The dump prints composition; a boot-only flag alongside it would be
// silently ignored, so reject the mix loud.
if (options.prompt !== undefined || options.resume !== undefined || options.configReplace !== undefined) {
program.error('error: --dump-config/--dump-default-config take none of -p/--prompt, --resume, or --config-replace')
}
resolved = dump
return
}
if (options.prompt !== undefined) {
// A headless prompt owns the invocation; an empty task has nothing to
// run, and --config/--resume are TUI inputs that must not silently
// vanish from a headless run.
if (options.prompt === '') program.error('error: --prompt needs a task')
if (options.config !== undefined || options.resume !== undefined) {
program.error('error: --prompt takes no --config or --resume')
if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) {
program.error('error: --prompt takes no --config, --config-replace, or --resume')
}
resolved = { mode: 'headless', prompt: options.prompt }
return
@@ -104,31 +220,96 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
// An empty --resume= id would silently start a fresh session downstream
// (agent-loop treats '' as no-resume), so a mistyped resume must fail loud.
if (options.resume === '') program.error('error: --resume needs a session id')
// The two config flags are mutually exclusive: one layers over the shipped
// tree, the other discards it, so accepting both would silently drop one.
if (options.config !== undefined && options.configReplace !== undefined) {
program.error('error: --config and --config-replace are mutually exclusive')
}
resolved = {
mode: 'tui',
...options.config !== undefined && { config: options.config },
...options.configReplace !== undefined && { configReplace: options.configReplace },
...options.resume !== undefined && { resume: options.resume },
}
})
const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
// Commander parses the parent (default-surface) options on either side of a
// subcommand into `program.opts()`. For a subcommand that shares none of them,
// a leaked config/prompt/resume option is a mistyped invocation that must fail
// loud rather than silently run and drop the input.
const rejectParentOptions = (command: string): void => {
const parent = program.opts<{
config?: string
configReplace?: string
prompt?: string
resume?: string
dumpConfig?: boolean
dumpDefaultConfig?: boolean
}>()
if (parent.config !== undefined || parent.configReplace !== undefined
|| parent.prompt !== undefined || parent.resume !== undefined
|| parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) {
program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, --resume, --dump-config, or --dump-default-config`)
}
}
// `meta` and `upgrade` are experimental: each runs only under its own
// `--experimental` flag or an environment-wide `DSH_EXPERIMENTAL=1` opt-in,
// and fails loud otherwise so the gate is never silently skipped.
const requireExperimental = (command: string, flag: boolean | undefined): void => {
if (flag !== true && !experimentalEnv) {
program.error(`error: ${command} is experimental; pass --experimental or set DSH_EXPERIMENTAL=1`)
}
}
// Registration order is the rendered help order, so daily use comes first
// and the harness-development surfaces (`web --dev`, `meta`)
// come last. `upgrade` is a guided fresh-session entry: beyond the
// experimental gate it takes no options and always mints a fresh session,
// so nothing is left to carry.
program
.command('upgrade')
.description('update this dsh installation to the latest version (experimental)')
.option('--experimental', 'acknowledge this subcommand is experimental')
.action((options: { experimental?: boolean }) => {
rejectParentOptions('upgrade')
requireExperimental('upgrade', options.experimental)
resolved = { mode: 'upgrade' }
})
// Host and port name no default: the CLI passes neither through when the flag
// is absent, so the shipped Web overlay value stands and restating it here
// would duplicate a fact this file does not own.
const web = program.command('web').description('serve the browser UI on the configured host and port')
web
.option('--host <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
.option('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
.option('--dev', 'developer mode: hot-reload the browser client')
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
.option('--dump-config', 'print the composed config tree (base + web + --config/personal overlay) and exit')
.option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
.action((options: WebOptions) => {
// Commander parses the parent (default-surface) options on either side of
// the subcommand into `program.opts()`. `web` shares none of them, so a
// leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
// fail loud rather than silently start the web server and drop it.
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
program.error('error: web takes none of --config, -p/--prompt, or --resume')
rejectParentOptions('web')
const dump = resolveDump('web', options, message => program.error(message))
if (dump !== undefined) {
resolved = dump
return
}
resolved = resolveWeb(options)
})
program
.command('meta')
.description('work on the dsh source that runs this command, from any directory (experimental)')
.option('--experimental', 'acknowledge this subcommand is experimental')
.action((options: { experimental?: boolean }) => {
rejectParentOptions('meta')
requireExperimental('meta', options.experimental)
resolved = { mode: 'meta' }
})
try {
program.parse(argv, { from: 'user' })
} catch (error) {

View File

@@ -25,12 +25,13 @@ function readVersion(): string {
}
loadEnv('dsh')
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
// The env opt-in is read at the process boundary; `1` is the documented value.
const invocation = parseDshArgs(process.argv.slice(2), readVersion(), process.env.DSH_EXPERIMENTAL === '1')
switch (invocation.mode) {
case 'web': {
const { runWeb } = await import('./web.ts')
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot)
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config)
break
}
case 'headless': {
@@ -40,7 +41,22 @@ switch (invocation.mode) {
}
case 'tui': {
const { runTui } = await import('./tui.ts')
await runTui(invocation.config, invocation.resume)
await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace)
break
}
case 'dump-config': {
const { runDumpConfig } = await import('./dump-config.ts')
runDumpConfig(invocation.surface, invocation.defaultOnly, invocation.config)
break
}
case 'meta': {
const { runTui, SOURCE_ROOT } = await import('./tui.ts')
await runTui(undefined, undefined, SOURCE_ROOT)
break
}
case 'upgrade': {
const { runTui } = await import('./tui.ts')
await runTui(undefined, undefined, undefined, `dsh-${invocation.mode}`)
break
}
default:

View File

@@ -0,0 +1,61 @@
/**
* `dsh --dump-config` / `dsh web --dump-config` — print the composed config
* tree without booting: the shipped base, the surface overlay, and (unless
* `--dump-default-config`) the `--config` or personal overlay, composed
* through the include's own patch algorithm so the printed tree is exactly
* what that surface would mount. `!!js` expressions print verbatim,
* unevaluated — the dump shows composition, not one process's environment.
* Launcher-provided boot-context values (session identity, CLI-flag patches)
* are per-invocation facts outside the config tree and do not appear.
* @module @deepseek-ai/dsh/dump-config
*/
import { basename, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import {
loadOverlayPatches,
loadPersonalPatches,
PERSONAL_CONFIG_FILENAME,
renderConfigDump,
type ConfigDumpLayer,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const NAME = 'dsh'
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const SURFACE_OVERLAYS = {
tui: fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url)),
web: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
} as const
/* v8 ignore start -- composition over the unit-tested renderConfigDump; the
built-bin e2e drives this path end to end */
/**
* Print one surface's composed config tree to stdout, with a comment
* separator naming the file each section of rows comes from (and the layers
* that patched it).
* @param surface - which surface overlay to compose over the shared base.
* @param defaultOnly - stop at the surface overlay (no `--config`/personal layer).
* @param config - the `--config` overlay path composed instead of the personal
* one, or `undefined` to use `$DSH_HOME/config.yaml`.
*/
export function runDumpConfig(surface: 'tui' | 'web', defaultOnly: boolean, config?: string): void {
const overlay = SURFACE_OVERLAYS[surface]
const layers: ConfigDumpLayer[] = [
{ label: basename(overlay), patches: loadOverlayPatches(NAME, overlay) },
]
if (!defaultOnly) {
if (config === undefined) {
const personal = loadPersonalPatches(NAME)
// The personal file may be absent; the shipped layers still print.
if (personal !== undefined) {
layers.push({ label: join(resolveDshHome(), PERSONAL_CONFIG_FILENAME), patches: personal })
}
} else {
layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
}
}
process.stdout.write(renderConfigDump(NAME, BASE_CONFIG, layers))
}
/* v8 ignore stop */

View File

@@ -1,6 +1,6 @@
/**
* `dsh -p "task"` — headless over the one shared composition: AppCLIEntry
* boots the same cordis.yml as `dsh web` (port 0, so parallel runs never
* 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
@@ -53,7 +53,7 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
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('')
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) {
@@ -75,12 +75,24 @@ async function consumeUntilTurnEnd(frames: AsyncIterable<RpcRequest<MuxFrame>>,
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('../cordis.yml', import.meta.url)),
configPath: fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url)),
overlayPath: fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url)),
dev: 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))

View File

@@ -0,0 +1,111 @@
/**
* Static terminal rasters derived from the official 24x24 DeepSeek icon.
*
* Source: `../../assets/deepseek-color.svg`, whose path data is copied exactly
* from the supplied official icon (viewBox `0 0 24 24`, fill `#4D6BFE`). Each
* tier rasterizes that path into a square binary
* mask without redrawing its contour. The Unicode form packs two source rows
* into `▀`/`▄`/`█`; the ASCII fallback packs the same two bits into
* `'`/`_`/`#`. Assets contain no ANSI and are never generated at runtime.
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-art
*/
/** Responsive official-icon raster tier. */
export type TuiFirstRunWelcomeArtTier = 'full' | 'compact' | 'minimal'
/** One raster with a block-cell primary and bit-equivalent ASCII fallback. */
export interface TuiFirstRunWelcomeArt {
/** Two vertical source pixels per terminal cell. */
readonly unicode: readonly string[]
/** Same two-bit cells encoded as top `'`, bottom `_`, and both `#`. */
readonly ascii: readonly string[]
}
const fullUnicode = Object.freeze([
' ▄',
' ▄▄▄▄▄▄▄▄▄▄███▀ ██▄',
' ▄███████████████▄ ████▄ ▄▄▄▄██',
' ▄███████████████████▄ ████████████▀',
' ▄██████████████████████▄ ▀█████████▀',
'▄███▀█████████████████████▄ ████▀▀',
'███ ▀▀█████████▀▀▀█████████▀',
'███ ▀███████▀█ ▀███████',
'███▄ ▀███████▄ ▀█████▀',
'▀███ ▀██████████████',
' ▀███▄ ▀███████████▀',
' ▀███▄ ▄▄▄ ▀████████▀',
' █████▄ ███▄▄ ▀█████▄▄',
' ▀█████████████▄▄▄▄█▀█████▀',
' ▀▀███████████▀▀',
])
const fullAscii = Object.freeze([
' _',
" __________###' ##_",
' _###############_ ####_ ____##',
" _###################_ ############'",
" _######################_ '#########'",
"_###'#####################_ ####''",
"### ''#########'''#########'",
"### '#######'# '#######",
"###_ '#######_ '#####'",
"'### '##############",
" '###_ '###########'",
" '###_ ___ '########'",
" #####_ ###__ '#####__",
" '#############____#'#####'",
" ''###########''",
])
const compactUnicode = Object.freeze([
' ▄▄▄▄▄▄▄██▀ █▄ ▄',
' ▄███████████▄▄ ███▄▄████',
' ████████████████▄ ▀██████▀',
'██▀▀▀▀▀████████████▄▄██▀',
'██ ▀█████▄ ▀█████',
'██▄ ▀████▄ ▄████',
' ██▄ ████████▀',
' ██▄ ▄▄ ▀█████▀',
' ▀███▄▄▄███▄ ████▄▄',
' ▀▀▀███████▀▀',
])
const compactAscii = Object.freeze([
" _______##' #_ _",
' _###########__ ###__####',
" ################_ '######'",
"##'''''############__##'",
"## '#####_ '#####",
"##_ '####_ _####",
" ##_ ########'",
" ##_ __ '#####'",
" '###___###_ ####__",
" '''#######''",
])
const minimalUnicode = Object.freeze([
' ▄▄▄▄▄▄ ▄▄',
' ▄████████▄ ▀████▀',
'█▀▀▀▀███████▄██▀',
'█▄ ▀███ ▀███',
'▀█▄ ▀█████',
' ▀█▄▄ █▄▄▀███▄',
' ▀▀▀▀▀▀',
])
const minimalAscii = Object.freeze([
' ______ __',
" _########_ '####'",
"#''''#######_##'",
"#_ '### '###",
"'#_ '#####",
" '#__ #__'###_",
" ''''''",
])
/** Exact-path terminal rasters by responsive tier. */
export const TUI_FIRST_RUN_WELCOME_WHALE = Object.freeze({
full: Object.freeze({ unicode: fullUnicode, ascii: fullAscii }),
compact: Object.freeze({ unicode: compactUnicode, ascii: compactAscii }),
minimal: Object.freeze({ unicode: minimalUnicode, ascii: minimalAscii }),
}) satisfies Readonly<Record<TuiFirstRunWelcomeArtTier, TuiFirstRunWelcomeArt>>

View File

@@ -0,0 +1,49 @@
/**
* Centrally owned version and all-locale Chinese copy for the shipped TUI first-run notice.
*
* A material wording change increments {@link TUI_FIRST_RUN_WELCOME_NOTICE_VERSION}
* so every Harness home presents the revised notice once.
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome-copy
*/
/** Copy version persisted after the user explicitly continues. */
export const TUI_FIRST_RUN_WELCOME_NOTICE_VERSION = 4
/** Locale-shaped text rendered by the first-run welcome overlay. */
export interface TuiFirstRunWelcomeNoticeCopy {
/** Overlay heading. */
readonly title: string
/** Ordered prose paragraphs. */
readonly paragraphs: readonly string[]
/** Enter action label. */
readonly continueLabel: string
/** Hint shown when the prose is scrollable. */
readonly scrollHint: string
/** Status shown while the acknowledgement reaches disk. */
readonly saving: string
/** Retry message shown when the acknowledgement cannot be persisted. */
readonly saveError: string
}
/** Complete Chinese notice used for every locale. */
const TUI_FIRST_RUN_WELCOME_CHINESE_COPY = Object.freeze<TuiFirstRunWelcomeNoticeCopy>({
title: 'DeepSeek Harness',
paragraphs: Object.freeze([
'感谢您愿意拨冗试用 DeepSeek Harness。当前版本仍处于内部测试阶段功能仍待完善体验难免有些粗糙。',
'“如切如磋,如琢如磨。” 产品的成长,离不开一次次真实的碰撞与坦诚的反馈。您在真实使用中发现的问题,也可能促使我们重新审视,甚至推翻已有的设计。',
'为了帮助我们更准确地还原您真实使用中的问题,内测版本默认会上传所有 Session Log如需关闭请设置环境变量 DSH_TELEMETRY_DISABLED=1。另外如果您有任何反馈与建议请在企业微信群中留言告诉我们。每一条反馈都会帮助我们把它打磨得更好。',
]),
continueLabel: '继续',
scrollHint: '↑/↓ 滚动',
saving: '正在保存确认…',
saveError: '无法保存确认,请按 Enter 重试。',
})
/** Locale map whose entries deliberately share the single Chinese owner copy. */
export const TUI_FIRST_RUN_WELCOME_NOTICE_COPY = Object.freeze({
'zh-CN': TUI_FIRST_RUN_WELCOME_CHINESE_COPY,
en: TUI_FIRST_RUN_WELCOME_CHINESE_COPY,
})
/** Locale presented by the shipped first-run notice. */
export const TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE = 'zh-CN' as const

View File

@@ -0,0 +1,385 @@
/**
* Effect-owned first-run overlay for the shipped `dsh` TUI.
*
* The launcher owns the per-DSH_HOME acknowledgement boundary; the component
* reaches the terminal only through the mounted `ctx.tui` overlay service and
* never touches the session or model context.
* @module @deepseek-ai/dsh/tui-onboarding/tui-first-run-welcome
*/
import { randomUUID } from 'node:crypto'
import { lstat, mkdir, open, rename, rm } from 'node:fs/promises'
import { basename, dirname, join } from 'node:path'
import type { Context } from 'cordis'
import {
Key,
matchesKey,
truncateToWidth,
visibleWidth,
wrapTextWithAnsi,
} from '@earendil-works/pi-tui'
import {
disposeRootAndExit,
type TuiComponent,
type TuiFocusable,
type TuiOverlayHost,
} from '@deepseek-ai/dsh-tui'
import {
TUI_FIRST_RUN_WELCOME_NOTICE_COPY,
TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE,
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
type TuiFirstRunWelcomeNoticeCopy,
} from './tui-first-run-welcome-copy.ts'
import {
TUI_FIRST_RUN_WELCOME_WHALE,
type TuiFirstRunWelcomeArtTier,
} from './tui-first-run-welcome-art.ts'
// TODO: Move acknowledgement persistence behind @deepseek-ai/dsh-storage once
// its backend contract supports concurrent host processes. This same-value
// marker must not inherit JSON lost updates or SQLite busy failures.
const ACKNOWLEDGEMENT_DIRECTORY = 'notices'
const ACKNOWLEDGEMENT_BASENAME = 'tui-first-run-welcome'
/** Cordis plugin name. */
export const name = 'tui-first-run-welcome'
/** The notice can open only after the terminal-local overlay service mounts. */
export const inject = ['tui']
/** Launcher-resolved configuration for the terminal-local notice. */
interface Config {
/** Absolute DeepSeek Harness home owning this acknowledgement. */
readonly dshHome: string
/** Render the bit-equivalent printable ASCII icon fallback. */
readonly asciiArt?: boolean
}
/**
* Detect an explicitly non-Unicode terminal locale for the static ASCII art fallback.
* @param env - Process environment carrying locale and terminal declarations.
* @returns `true` only when the environment explicitly declares an ASCII-only locale or dumb terminal.
*/
export function needsTuiFirstRunWelcomeAsciiArt(
env: Readonly<Record<string, string | undefined>> = process.env,
): boolean {
const locale = env.LC_ALL ?? env.LC_CTYPE ?? env.LANG
return env.TERM === 'dumb' || locale === 'C' || locale === 'POSIX'
}
/**
* Resolve the immutable marker for one notice version.
* @param dshHome - Resolved Harness home.
* @param version - Copy version whose acknowledgement is queried.
* @returns Absolute marker path beneath the Harness home.
*/
export function tuiFirstRunWelcomeAcknowledgementPath(dshHome: string, version: number): string {
return join(
dshHome,
ACKNOWLEDGEMENT_DIRECTORY,
`${ACKNOWLEDGEMENT_BASENAME}-v${String(version)}.ack`,
)
}
/**
* Test whether one notice version has been acknowledged.
* @param dshHome - Resolved Harness home.
* @param version - Copy version to inspect.
* @returns `true` only for a regular marker file; a malformed marker fails loud.
*/
export async function hasTuiFirstRunWelcomeAcknowledgement(
dshHome: string,
version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
): Promise<boolean> {
const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version)
try {
const info = await lstat(path)
if (!info.isFile()) throw new Error(`TUI welcome acknowledgement is not a file: ${path}`)
return true
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return false
throw error
}
}
/**
* Persist one version acknowledgement by syncing a random same-directory file
* before atomically replacing the immutable marker. Concurrent launches publish
* the same fact, so same-value last-writer-wins replacement loses no state.
* @param dshHome - Resolved Harness home.
* @param version - Copy version being acknowledged.
*/
export async function acknowledgeTuiFirstRunWelcome(
dshHome: string,
version: number = TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
): Promise<void> {
const path = tuiFirstRunWelcomeAcknowledgementPath(dshHome, version)
const directory = dirname(path)
const temp = join(directory, `.${basename(path)}.${randomUUID()}.tmp`)
await mkdir(directory, { recursive: true, mode: 0o700 })
await syncDirectory(dirname(directory))
let handle: Awaited<ReturnType<typeof open>> | undefined
try {
handle = await open(temp, 'wx', 0o600)
await handle.sync()
const created = handle
handle = undefined
await created.close()
await rename(temp, path)
} catch (error) {
/* v8 ignore start -- fault-injected UI coverage proves failed acknowledgements stay uncommitted and retryable */
try {
await handle?.close()
} finally {
await rm(temp, { force: true })
}
throw error
/* v8 ignore stop */
}
try {
await syncDirectory(directory)
/* v8 ignore next -- rename is the commit point; directory-fsync fault injection is platform-specific */
} catch {
// Swallow post-rename directory fsync failure: the marker is already committed,
// and crash loss can only make the notice reappear on the safe side.
}
}
/** Sync one POSIX directory after publishing a child entry. */
/* v8 ignore start -- Windows rejects directory opens; POSIX unit coverage owns this path. */
async function syncDirectory(path: string): Promise<void> {
if (process.platform === 'win32') return
const handle = await open(path, 'r')
try {
await handle.sync()
} finally {
await handle.close()
}
}
/* v8 ignore stop */
/** Render one visible-width-padded line inside the notice frame. */
function framed(content: string, innerWidth: number, host: TuiOverlayHost): string {
const clipped = truncateToWidth(content, innerWidth, '')
return `${host.theme.dim('│')} ${clipped}${' '.repeat(Math.max(0, innerWidth - visibleWidth(clipped)))} ${host.theme.dim('│')}`
}
/** Center one line by terminal column width. */
function centered(content: string, width: number): string {
const clipped = truncateToWidth(content, width, '')
const remaining = Math.max(0, width - visibleWidth(clipped))
return `${' '.repeat(Math.floor(remaining / 2))}${clipped}`
}
/**
* Select the art tier for the actual overlay width and viewport height.
* @param innerWidth - Columns inside the frame.
* @param viewportRows - Current terminal rows.
* @returns full, compact, minimal, or no art when prose must take priority.
*/
export function tuiFirstRunWelcomeArtTier(
innerWidth: number,
viewportRows: number,
): TuiFirstRunWelcomeArtTier | undefined {
const compositionCapacity = Math.max(1, Math.max(7, Math.floor(viewportRows * 0.9)) - 5)
if (innerWidth >= 96 && TUI_FIRST_RUN_WELCOME_WHALE.full.unicode.length <= compositionCapacity) return 'full'
if (innerWidth >= 80 && TUI_FIRST_RUN_WELCOME_WHALE.compact.unicode.length + 4 <= compositionCapacity) return 'compact'
if (innerWidth >= 64 && TUI_FIRST_RUN_WELCOME_WHALE.minimal.unicode.length + 4 <= compositionCapacity) return 'minimal'
return undefined
}
/** Wrap the centrally owned prose while promoting its opening quotation. */
function proseLines(
copy: TuiFirstRunWelcomeNoticeCopy,
width: number,
host: TuiOverlayHost,
): string[] {
const lines: string[] = []
for (const [index, paragraph] of copy.paragraphs.entries()) {
if (index > 0) lines.push('')
const quoteEnd = paragraph.startsWith('“') ? paragraph.indexOf('”') : -1
if (quoteEnd > 0) {
const quote = paragraph.slice(0, quoteEnd + 1)
const remainder = paragraph.slice(quoteEnd + 1).trimStart()
lines.push(...wrapTextWithAnsi(host.theme.bold(host.theme.text(host.display(quote))), width))
lines.push('')
if (remainder !== '') lines.push(...wrapTextWithAnsi(host.theme.text(host.display(remainder)), width))
} else {
lines.push(...wrapTextWithAnsi(host.theme.text(host.display(paragraph)), width))
}
}
return lines
}
/** Render centered static brand art without putting ANSI into its owner file. */
function artLines(
tier: TuiFirstRunWelcomeArtTier,
width: number,
host: TuiOverlayHost,
asciiArt: boolean,
): string[] {
const art = TUI_FIRST_RUN_WELCOME_WHALE[tier][asciiArt ? 'ascii' : 'unicode']
return art.map(line => centered(host.theme.brand(line), width))
}
/** Responsive, scrollable notice whose only completion input is Enter. */
export class TuiFirstRunWelcomeComponent implements TuiComponent, TuiFocusable {
focused = false
private scrollOffset = 0
private bodyCapacity = 1
private maxScrollOffset = 0
private saving = false
private saveFailed = false
constructor(
private readonly host: TuiOverlayHost,
private readonly copy: TuiFirstRunWelcomeNoticeCopy,
private readonly acknowledge: () => Promise<void>,
private readonly exit: () => void,
private readonly asciiArt = false,
) {}
invalidate(): void {}
render(width: number): string[] {
const frameWidth = Math.max(6, width)
const innerWidth = Math.max(1, frameWidth - 4)
const viewportRows = this.host.viewport.rows
const tier = tuiFirstRunWelcomeArtTier(innerWidth, viewportRows)
const availableRows = Math.max(7, Math.floor(viewportRows * 0.9))
const title = this.host.theme.bold(this.host.theme.brand(this.copy.title))
let fixedHeader: string[] = []
let fullContentHeader: string[] = []
let body: string[]
let fullArt: string[] | undefined
const fullArtWidth = 44
if (tier === 'full') {
fullArt = artLines(tier, fullArtWidth, this.host, this.asciiArt)
const contentWidth = Math.max(1, innerWidth - fullArtWidth - 3)
fullContentHeader = [centered(title, contentWidth), '']
body = proseLines(this.copy, contentWidth, this.host)
} else {
const art = tier === undefined ? [] : artLines(tier, innerWidth, this.host, this.asciiArt)
fixedHeader = [...art, ...art.length === 0 ? [] : [''], centered(title, innerWidth), '']
body = proseLines(this.copy, innerWidth, this.host)
}
const compositionCapacity = Math.max(1, availableRows - 5)
const bodyLimit = Math.max(1, compositionCapacity - fixedHeader.length - fullContentHeader.length)
this.bodyCapacity = Math.min(body.length, bodyLimit)
const maxOffset = Math.max(0, body.length - this.bodyCapacity)
this.maxScrollOffset = maxOffset
this.scrollOffset = Math.min(this.scrollOffset, maxOffset)
const visibleBody = body.slice(this.scrollOffset, this.scrollOffset + this.bodyCapacity)
const top = this.host.theme.dim(`${'─'.repeat(Math.max(0, frameWidth - 2))}`)
const separator = this.host.theme.dim(`${'─'.repeat(Math.max(0, frameWidth - 2))}`)
const bottom = this.host.theme.dim(`${'─'.repeat(Math.max(0, frameWidth - 2))}`)
const action = this.host.theme.bold(this.host.theme.accent(`Enter ${this.copy.continueLabel}`))
const hasAbove = this.scrollOffset > 0
const hasBelow = this.scrollOffset < maxOffset
const scroll = hasAbove || hasBelow
? `${hasAbove ? '↑' : ' '} ${this.copy.scrollHint} ${hasBelow ? '↓' : ' '}`
: ''
const status = this.saveFailed
? this.host.theme.error(this.copy.saveError)
: this.saving
? this.host.theme.dim(this.copy.saving)
: this.host.theme.dim(scroll)
const fullContent = [...fullContentHeader, ...visibleBody]
const composition = fullArt === undefined
? [...fixedHeader, ...visibleBody]
: Array.from({ length: Math.max(fullArt.length, fullContent.length) }, (_, index) => {
const art = fullArt[index] ?? ''
const line = fullContent[index] ?? ''
const left = `${art}${' '.repeat(Math.max(0, fullArtWidth - visibleWidth(art)))}`
return `${left} ${line}`
})
return [
top,
...composition.map(line => framed(line, innerWidth, this.host)),
separator,
framed(centered(action, innerWidth), innerWidth, this.host),
framed(centered(status, innerWidth), innerWidth, this.host),
bottom,
]
}
handleInput(data: string): void {
if (matchesKey(data, Key.ctrl('c')) || matchesKey(data, Key.ctrl('d'))) {
this.exit()
return
}
if (matchesKey(data, Key.enter)) {
if (!this.saving) void this.commit()
return
}
if (this.saving || matchesKey(data, Key.escape)) return
if (matchesKey(data, Key.up)) this.scrollBy(-1)
else if (matchesKey(data, Key.down)) this.scrollBy(1)
else if (matchesKey(data, Key.pageUp)) this.scrollBy(-this.bodyCapacity)
else if (matchesKey(data, Key.pageDown)) this.scrollBy(this.bodyCapacity)
else if (matchesKey(data, Key.home)) this.scrollTo(0)
else if (matchesKey(data, Key.end)) this.scrollTo(this.maxScrollOffset)
}
private scrollBy(delta: number): void {
this.scrollTo(this.scrollOffset + delta)
}
private scrollTo(offset: number): void {
this.scrollOffset = Math.min(this.maxScrollOffset, Math.max(0, offset))
this.host.invalidate()
}
private async commit(): Promise<void> {
this.saving = true
this.saveFailed = false
this.host.invalidate()
try {
await this.acknowledge()
this.host.close()
} catch {
this.saving = false
this.saveFailed = true
this.host.invalidate()
}
}
}
/**
* Open the first-run notice through the mounted TUI's FIFO overlay owner.
* @param ctx - Plugin context carrying the terminal-local TUI service.
* @param config - Launcher-resolved Harness home.
*/
export function apply(ctx: Context, config: Config): void {
const copy = TUI_FIRST_RUN_WELCOME_NOTICE_COPY[TUI_FIRST_RUN_WELCOME_NOTICE_LOCALE]
const pending = new Set<Promise<void>>()
const acknowledge = (): Promise<void> => {
const task = acknowledgeTuiFirstRunWelcome(config.dshHome)
pending.add(task)
const settled = (): void => { pending.delete(task) }
void task.then(settled, settled)
return task
}
ctx.effect(() => async () => {
await Promise.allSettled(pending)
}, 'tui first-run welcome acknowledgement')
ctx.tui.openOverlay({
create: host => new TuiFirstRunWelcomeComponent(
host,
copy,
acknowledge,
() => { disposeRootAndExit(ctx, 0) },
config.asciiArt ?? false,
),
options: {
width: '100%',
maxHeight: '90%',
anchor: 'center',
margin: 0,
},
})
}

View File

@@ -1,55 +1,109 @@
/**
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
* tui-agent config (or the `--config` override) with the personal overlay
* shared base and TUI overlay, followed by either `--config` or the personal overlay
* from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence:
* ambient environment, then the invoking directory's `.env`, then the personal one)
* and its `config.yaml` patches the booted tree. The workspace is the invoking
* directory: sessions, relative paths, and workspace instructions resolve from
* the cwd, so `dsh` acts on whatever project it is launched in. After boot, the
* agent's system prompt is told the path to this harness checkout so it can find
* its own source.
* directory: the session cwd, relative paths, and workspace instructions resolve
* from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every
* workspace, and an in-place resume enters the selected session's own directory.
* `dsh meta` is the one exception — it makes this harness
* checkout the workspace. `dsh upgrade` is a fresh session whose
* first turn auto-invokes a bundled skill. After boot, the agent's system
* prompt is told the path to this harness checkout so it can find its own
* source.
* @module @deepseek-ai/dsh/tui
*/
import { join } from 'node:path'
import { randomUUID } from 'node:crypto'
import { rm } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath } from 'node:url'
import {
addHarnessSourceSection,
boot,
installFailLoud,
loadEnv,
loadOverlayPatches,
loadPersonalPatches,
RESUME_SESSION_ID_KEY,
resolveConfigPath,
} from '@deepseek-ai/dsh-app-boot'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
import { SessionId } from '@deepseek-ai/dsh-session'
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite'
import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop'
import type { Context } from 'cordis'
import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
import {
INITIAL_SKILL_KEY,
MAIN_SESSION_ID_KEY,
TUI_GOODBYE_MESSAGE_KEY,
type MainSessionIdentity,
type TuiResumeHost,
} from '@deepseek-ai/dsh-tui'
import {
apply as applyTuiFirstRunWelcome,
hasTuiFirstRunWelcomeAcknowledgement,
inject as tuiFirstRunWelcomeInject,
name as tuiFirstRunWelcomeName,
needsTuiFirstRunWelcomeAsciiArt,
} from './tui-onboarding/tui-first-run-welcome.ts'
import {
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
} from './tui-onboarding/tui-first-run-welcome-copy.ts'
const NAME = 'dsh'
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
// one directory under apps/cli, so the shipped default config resolves with
// the same relative hop from either artifact.
const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis.yml', import.meta.url))
// The shared core every `dsh` surface mounts, and the TUI's own overlay over
// it. Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib)
// sit one directory under apps/cli, so each resolves with the same hop.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const TUI_OVERLAY = fileURLToPath(new URL('../config/tui.cordis.yml', import.meta.url))
// The `agents` entry in tui.cordis.yml the TUI drives; the launcher binds its
// session identity by this config id.
const MAIN_AGENT_ID = 'main'
/** Per-process filename of the disposable `/resume` index. */
const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.db`
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
// from this bin's location so it holds however `dsh` is launched (a PATH
// symlink, an arbitrary cwd). The agent is told where its own source lives.
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** The harness checkout used as the `dsh meta` workspace and source prompt path. */
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
the tui-agent PTY smoke drives this path end to end, personal overlay included */
the CLI PTY smoke drives this path end to end, personal overlay included */
/**
* Run the interactive TUI from the invoking directory.
* @param config - a config path to boot instead of the shipped default, or
* `undefined` for the default; already parsed from `--config`.
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
* already parsed and non-empty-validated from `--resume`. It is provided on the
* boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
* reads through `!!js` to rehydrate that session.
* @param config - an overlay patch list applied over the shared base and the
* TUI overlay, REPLACING the personal `~/.dsh/config.yaml` so a named tree never
* inherits the user's route, or `undefined` to use the personal overlay;
* already parsed from `--config`.
* @param resumeSessionId - a persisted session id to resume, or `undefined` to
* mint a fresh one; already parsed and non-empty-validated from `--resume`.
* Either way the resulting identity reaches the booted app through
* {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session
* and an overlay replacing the agent row cannot drop it.
* @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
* first turn, or `undefined`. Set only by `dsh upgrade` and
* ignored on a resume, so it never re-fires; reaches the app through
* {@link INITIAL_SKILL_KEY}.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
* to compose them; already parsed from `--config-replace`.
*/
export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise<void> {
export async function runTui(
config: string | undefined,
resumeSessionId: string | undefined,
workspace?: string,
initialSkill?: string,
configReplace?: string,
): Promise<void> {
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
// is logged per-entry rather than rethrown, so a piped launch would
// otherwise settle into an idle UI-less process instead of exiting nonzero.
@@ -60,29 +114,67 @@ export async function runTui(config: string | undefined, resumeSessionId: string
process.exit(1)
}
installFailLoud(NAME)
// The bin already loaded the invoking directory's .env; the personal .env
// only fills what is still unset (process.loadEnvFile never overrides).
loadEnv(NAME, resolveDshHome())
// The bin already loaded the invoking directory's .env, and that is the
// whole environment: $DSH_HOME/.env is credentials-local's writable store,
// and hoisting it would make every stored key read as a read-only ambient
// override on the next run — unrotatable from the TUI or the web page.
// The environment is settled, so switching the workspace here cannot alter
// its precedence. The cwd IS the workspace seam: the shipped config
// resolves the session cwd and the HMR watch root from it, so one chdir moves
// both together. Sessions themselves live under the Harness home so `/resume`
// spans every workspace, and are unaffected by this chdir.
if (workspace !== undefined) process.chdir(workspace)
const dshHome = resolveDshHome()
const showFirstRunWelcome = !await hasTuiFirstRunWelcomeAcknowledgement(
dshHome,
TUI_FIRST_RUN_WELCOME_NOTICE_VERSION,
)
process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills')
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
// flag, so the resumed process rehydrates through this same intake. The host
// is offered only when Node exposes `process.execve` and knows its own entry.
// flag, so the resumed process rehydrates through this same intake. The
// selected session may belong to another workspace, so the handoff also enters
// that directory. The host is offered only when Node exposes `process.execve`
// and knows its own entry.
const resolvedConfig = config === undefined ? undefined : resolve(config)
const resolvedConfigReplace = configReplace === undefined ? undefined : resolve(configReplace)
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
// Resume always enters the default surface because meta rejects
// parent options, including `--resume`. The resumed session already persists
// its cwd.
const resumeArgs = (sessionId: string): string[] => [
`--resume=${sessionId}`,
// Both config flags must survive the handoff: resuming into a different
// tree than the session was created in would silently change the agent.
...resolvedConfig !== undefined ? ['--config', resolvedConfig] : [],
...resolvedConfigReplace !== undefined ? ['--config-replace', resolvedConfigReplace] : [],
]
// Mint the fresh id here rather than in the app bundle: the exit line names
// the session to resume, so the launcher must know it before the tree boots.
const identity: MainSessionIdentity = resumeSessionId === undefined
? { id: SessionId(`main-session-${randomUUID()}`), resume: false }
: { id: SessionId(resumeSessionId), resume: true }
const goodbye = `To resume this session: ${NAME} ${resumeArgs(identity.id).join(' ')}`
const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
async handoff(sessionId): Promise<never> {
async handoff(sessionId, cwd): Promise<never> {
const current = app.current
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
// Rebuild argv from the parsed config plus the selected id: TUI mode's
// only arguments are `--config <path>` and `--resume <id>`.
const nextArgv = [
process.execPath,
...process.execArgv,
entry,
`--resume=${sessionId}`,
...config !== undefined ? ['--config', config] : [],
...resumeArgs(sessionId),
]
// `execve` inherits the cwd, and the target session may belong to another
// workspace. Enter it BEFORE teardown commits: an unreachable directory
// (deleted, unreadable) must reject while the caller can still restore the
// terminal, and a chdir after disposal would have no owner to report to.
try {
process.chdir(cwd)
} catch (error) {
throw new Error(`${NAME}: cannot resume in "${cwd}": ${String(error)}`)
}
try {
await current.fiber.dispose()
execve(process.execPath, nextArgv, process.env)
@@ -93,18 +185,78 @@ export async function runTui(config: string | undefined, resumeSessionId: string
}
},
}
// One include of the shared base, with every overlay applied as a sibling
// patch list: patches never cross an include boundary, so stacking these as
// nested includes would silently stop reaching base rows. Later lists win.
//
// `--config` REPLACES the personal overlay rather than layering under it: an
// explicitly named tree must not inherit `~/.dsh/config.yaml`'s route, or a
// demo or test config would silently run on the user's provider and model.
// `--config-replace` additionally discards the base and the surface overlay.
const replaceTree = configReplace !== undefined
const bootConfig = resolvedConfigReplace === undefined ? BASE_CONFIG : resolveConfigPath(resolvedConfigReplace, undefined)
// Same opt-out semantics as the web surface (resolveTelemetryPatch: any
// non-empty value disables; setting the switch against a tree without the
// row fails loud rather than silently no-opping a privacy switch). The row
// presence is checked against the tree actually booting, so a
// --config-replace tree is judged on its own rows, not the shipped base's.
const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, configHasTelemetryRow(bootConfig))
const patches = [
...replaceTree ? [] : [
...loadOverlayPatches(NAME, TUI_OVERLAY),
...resolvedConfig === undefined
? loadPersonalPatches(NAME) ?? []
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
],
...telemetryPatch === undefined ? [] : [telemetryPatch],
]
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
const ctx = await boot(
NAME,
resolveConfigPath(config ?? DEFAULT_CONFIG, undefined),
loadPersonalPatches(NAME),
bootConfig,
patches,
(hostCtx) => {
// Inject the resume id (or undefined) so the shipped config's `!!js`
// reads it as a bare identifier; then offer the in-place handoff host.
hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
// The launcher owns session identity and the exit line: a config-mounted
// app bundle reads both from these slots, so no cordis.yml key can drop
// resume.
hostCtx.provide(MAIN_SESSION_ID_KEY, identity)
hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye)
// Shared-store policy is the launcher's: sessions live in one root under
// the Harness home across every cwd, so /resume sees every workspace.
// The bundle treats the slot as opaque.
// The agent-loop row reads this to bind `main`, and the tui row reads the
// same id, so a personal overlay repointing the model route cannot drop
// the session identity or desynchronise the two.
hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity })
// The query database is a disposable derived index with single-process
// ownership. Keep it process-local while it indexes the shared logs.
hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, queryIndexPath)
hostCtx.effect(() => async () => {
await Promise.all([
rm(queryIndexPath, { force: true }),
rm(`${queryIndexPath}-wal`, { force: true }),
rm(`${queryIndexPath}-shm`, { force: true }),
])
}, `${SESSION_QUERY_SQLITE_PATH_KEY}.cleanup`)
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
// Seed the first turn only for a fresh session, so resuming never
// re-invokes the skill.
if (initialSkill !== undefined && resumeSessionId === undefined) {
hostCtx.provide(INITIAL_SKILL_KEY, initialSkill)
}
},
)
app.current = ctx
addHarnessSourceSection(ctx, SOURCE_ROOT)
if (showFirstRunWelcome) {
await ctx.plugin({
name: tuiFirstRunWelcomeName,
inject: tuiFirstRunWelcomeInject,
apply: applyTuiFirstRunWelcome,
}, {
dshHome,
asciiArt: needsTuiFirstRunWelcomeAsciiArt(),
})
}
}
/* v8 ignore stop */

View File

@@ -1,19 +1,20 @@
/**
* `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the
* already-parsed host/port/dev, print the URL line, wire signals. All
* composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and
* composition lives in the shared base plus Web overlay; all boot glue lives in AppCLIEntry. Host and
* port are unvalidated pass-through overrides — the `dsh-host-webserver` schema
* gates them at boot.
*/
import { networkInterfaces } from 'node:os'
import { fileURLToPath } from 'node:url'
import type { Context } from 'cordis'
import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot'
import { addHarnessSourceSection, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
import type {} from '@deepseek-ai/dsh-system-prompt'
import { AppCLIEntry } from './app-cli-entry.ts'
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
// The shared core every `dsh` surface mounts, plus this surface's overlay over it.
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/** Stable model-visible orientation for sessions created through `dsh web`. */
@@ -35,32 +36,39 @@ export function installWebPromptContext(ctx: Context, sourceRoot: string): void
systemPrompt.section({ name: 'app:web-surface', order: -98, text: WEB_SURFACE_PROMPT })
}
// Display-only mirrors of the webserver schema's allowed hosts: the loopback
// address the local URL always prints, and the all-interfaces value that gates
// LAN-address discovery. Not a source of truth — the schema is.
// Display-only mirror of the webserver schema's loopback host: the address the
// local URL always prints. Not a source of truth — the schema is.
const LOOPBACK_HOST = '127.0.0.1'
const ALL_INTERFACES_HOST = '0.0.0.0'
/**
* Serve the browser UI from the shipped config tree. `host`/`port` are passed
* through only when the flag was given; absent, the `cordis.yml` value stands.
* through only when the flag was given; absent, the shipped Web overlay value stands.
* @param host - the bind host, or `undefined` to keep the config default.
* @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default.
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
* @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback.
* @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone.
* @param config - an overlay of loader patches applied over the shipped web
* composition instead of `$DSH_HOME/config.yaml`, or `undefined` to use the
* personal overlay; already parsed from `--config`.
*/
export async function runWeb(
host: string | undefined,
port: number | undefined,
dev: boolean,
workspaceRoot: string | undefined,
trustedHosts: string[] | undefined,
config?: string,
): Promise<void> {
const entry = new AppCLIEntry({
configPath: CONFIG_PATH,
configPath: BASE_CONFIG,
overlayPath: WEB_OVERLAY,
...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) },
dev,
...host !== undefined && { host },
...port !== undefined && { port },
...workspaceRoot !== undefined && { workspaceRoot },
...trustedHosts !== undefined && { trustedHosts },
})
const { ctx, port: boundPort } = await entry.run()
installWebPromptContext(ctx, SOURCE_ROOT)
@@ -72,13 +80,14 @@ export async function runWeb(
void Promise.resolve(ctx.fiber.dispose()).finally(() => { process.exit(code) })
}
const lanCandidate = host === ALL_INTERFACES_HOST
? Object.values(networkInterfaces()).flat()
.find(iface => iface !== undefined && iface.family === 'IPv4' && !iface.internal)
: undefined
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate.address}:${boundPort})`}`)
// Install shutdown handling before publishing readiness: supervisors may
// send a signal as soon as they observe the URL line.
process.on('SIGTERM', () => { shutdown(0) })
process.on('SIGINT', () => { shutdown(130) })
// The entry's boot-time snapshot, not a fresh sample: the printed LAN URL
// must name an address the /api trust fence was configured with.
const lanCandidate = entry.lanAddresses[0]
const localUrl = `http://${LOOPBACK_HOST}:${boundPort}`
console.log(`dsh web: ${localUrl}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${boundPort})`}`)
}