mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
cleanup: remove TUI package and legacy dsh entrypoints
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share
|
||||
* (`dsh web` and `dsh -p`; the TUI composes dsh-app-boot directly).
|
||||
* (`dsh web` and `dsh -p`).
|
||||
* Everything here is what must exist before the Loader runs: the patch
|
||||
* composition over the shipped base and surface overlay (profile json + CLI
|
||||
* composition over the shipped base and Web 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
|
||||
@@ -89,7 +89,7 @@ export function resolveTelemetryPatch(disabledEnv: string | undefined, hasRow: b
|
||||
/**
|
||||
* 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).
|
||||
* that compose their patch lists outside {@link AppCLIEntry} (raw `dsh`).
|
||||
* @param file - absolute path of the config or overlay file.
|
||||
* @returns true when a top-level (or inserted) row has the telemetry id.
|
||||
*/
|
||||
|
||||
@@ -1,44 +1,26 @@
|
||||
/**
|
||||
* 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;
|
||||
* `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.
|
||||
* Commander adapter for the `dsh` command-line entry. The default command
|
||||
* boots one required `--config` overlay over the shipped base; `-p` selects
|
||||
* the one-shot headless path and `web` selects the browser application.
|
||||
* Commander owns help, version, and parse errors.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
|
||||
import { Command, CommanderError } from 'commander'
|
||||
|
||||
/**
|
||||
* 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
|
||||
/** Boot a caller-selected overlay over the shipped base config. */
|
||||
interface ConfigInvocation {
|
||||
mode: 'config'
|
||||
config: 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).
|
||||
*/
|
||||
/** Print a composed config tree and exit without booting. */
|
||||
interface DumpConfigInvocation {
|
||||
mode: 'dump-config'
|
||||
surface: 'tui' | 'web'
|
||||
/** Omit the `--config`/personal layer and print only the shipped composition. */
|
||||
surface: 'config' | 'web'
|
||||
/** Omit every caller or personal layer and print the shipped tree. */
|
||||
defaultOnly: boolean
|
||||
/** The `--config` overlay to compose instead of the personal one. */
|
||||
/** Explicit overlay to compose over the base or Web surface. */
|
||||
config?: string
|
||||
}
|
||||
|
||||
@@ -48,52 +30,24 @@ interface HeadlessInvocation {
|
||||
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 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.
|
||||
* Browser UI: `dsh web`. Host and port remain unvalidated pass-throughs to
|
||||
* the webserver schema; absent values leave the shipped Web overlay intact.
|
||||
*/
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
/** Overlay of loader patches applied over the shipped web composition. */
|
||||
/** Overlay applied over the shipped Web composition instead of the personal one. */
|
||||
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. */
|
||||
/** Extra authorities for the /api browser-trust fence. */
|
||||
trustedHosts?: string[]
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation =
|
||||
| TuiInvocation
|
||||
| DumpConfigInvocation
|
||||
| HeadlessInvocation
|
||||
| MetaInvocation
|
||||
| SkillSessionInvocation
|
||||
| WebInvocation
|
||||
/** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation = ConfigInvocation | DumpConfigInvocation | HeadlessInvocation | WebInvocation
|
||||
|
||||
/** Raw web-subcommand options straight from Commander. */
|
||||
interface WebOptions {
|
||||
@@ -107,13 +61,9 @@ interface WebOptions {
|
||||
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`.
|
||||
*/
|
||||
/** Resolve config-dump flags for one command shape. */
|
||||
function resolveDump(
|
||||
surface: 'tui' | 'web',
|
||||
surface: 'config' | 'web',
|
||||
options: { config?: string; dumpConfig?: boolean; dumpDefaultConfig?: boolean },
|
||||
error: (message: string) => never,
|
||||
): DumpConfigInvocation | undefined {
|
||||
@@ -125,6 +75,9 @@ function resolveDump(
|
||||
if (defaultOnly && options.config !== undefined) {
|
||||
error('error: --dump-default-config prints the shipped tree and takes no --config')
|
||||
}
|
||||
if (surface === 'config' && !defaultOnly && options.config === undefined) {
|
||||
error('error: --dump-config requires --config <path>')
|
||||
}
|
||||
return {
|
||||
mode: 'dump-config',
|
||||
surface,
|
||||
@@ -133,12 +86,7 @@ function resolveDump(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow the raw `web` options into a {@link WebInvocation}. No host/port
|
||||
* validation: both flow to the webserver schema, which is the sole gate. `port`
|
||||
* is coerced to a number (the schema rejects a string) but not range-checked
|
||||
* here — `NaN`/out-of-range fail loud at the schema on boot.
|
||||
*/
|
||||
/** Narrow raw `web` options into a {@link WebInvocation}. */
|
||||
function resolveWeb(options: WebOptions): WebInvocation {
|
||||
return {
|
||||
mode: 'web',
|
||||
@@ -152,137 +100,72 @@ function resolveWeb(options: WebOptions): WebInvocation {
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the raw argv into a {@link DshInvocation}, or print and exit for
|
||||
* `--help`/`--version`/a parse error. The default (no subcommand) is the
|
||||
* 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).
|
||||
* Resolve argv into one invocation, or print and exit for help, version, or an
|
||||
* error.
|
||||
* @param argv - arguments after the Node binary and script.
|
||||
* @param version - version string printed by `--version`.
|
||||
* @returns the resolved invocation.
|
||||
*/
|
||||
export function parseDshArgs(argv: readonly string[], version: string, experimentalEnv: boolean): DshInvocation {
|
||||
export function parseDshArgs(argv: readonly string[], version: string): DshInvocation {
|
||||
let resolved: DshInvocation | undefined
|
||||
const program = new Command()
|
||||
.name('dsh')
|
||||
.version(version, '-V, --version', 'output the version number')
|
||||
.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`.
|
||||
.description('dsh: boot a DeepSeek Harness config overlay over the shipped base configuration.')
|
||||
.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
|
||||
dsh --config ./app.cordis.yml boot an overlay over the shipped base
|
||||
dsh -p "run the tests" answer one task, print the result, and exit
|
||||
dsh web serve the browser UI
|
||||
`)
|
||||
.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('-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')
|
||||
.option('-p, --prompt <task>', 'answer this task without an interactive UI, then exit')
|
||||
.option('--config <path>', 'overlay of loader patches to apply over the shipped base')
|
||||
.option('--dump-config', 'print the base plus --config overlay and exit')
|
||||
.option('--dump-default-config', 'print the shipped base config 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 (options.config === '') program.error('error: --config needs a path')
|
||||
const dump = resolveDump('config', 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')
|
||||
if (options.prompt !== undefined) {
|
||||
program.error('error: --dump-config/--dump-default-config take no -p/--prompt')
|
||||
}
|
||||
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.configReplace !== undefined || options.resume !== undefined) {
|
||||
program.error('error: --prompt takes no --config, --config-replace, or --resume')
|
||||
}
|
||||
if (options.config !== undefined) program.error('error: --prompt takes no --config')
|
||||
resolved = { mode: 'headless', prompt: options.prompt }
|
||||
return
|
||||
}
|
||||
// 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 config = options.config ?? program.error('error: --config <path> is required')
|
||||
resolved = { mode: 'config', 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.
|
||||
/** Reject parent options that crossed a subcommand boundary. */
|
||||
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
|
||||
if (parent.config !== undefined || parent.prompt !== 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`)
|
||||
program.error(`error: ${command} takes none of parent --config, -p/--prompt, --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('--config <path>', 'apply this overlay of loader patches over the shipped configuration')
|
||||
.option('--config <path>', 'apply this overlay of loader patches over the shipped Web 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', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)')
|
||||
@@ -292,6 +175,7 @@ Examples:
|
||||
.option('--dump-default-config', 'print the shipped config tree (base + web overlay, no user layer) and exit')
|
||||
.action((options: WebOptions) => {
|
||||
rejectParentOptions('web')
|
||||
if (options.config === '') program.error('error: --config needs a path')
|
||||
const dump = resolveDump('web', options, message => program.error(message))
|
||||
if (dump !== undefined) {
|
||||
resolved = dump
|
||||
@@ -300,25 +184,12 @@ Examples:
|
||||
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) {
|
||||
// Commander printed help/version/the error under `exitOverride`; exit with
|
||||
// the code it chose (0 for help/version, 1 for a parse or domain error).
|
||||
/* v8 ignore next -- Commander only throws CommanderError from parse/error under exitOverride */
|
||||
return process.exit(error instanceof CommanderError ? error.exitCode : 1)
|
||||
}
|
||||
/* v8 ignore next -- the default action or a subcommand action always resolves, or parse throws above */
|
||||
/* v8 ignore next -- an action resolves or Commander throws */
|
||||
if (resolved === undefined) throw new Error('dsh: no invocation resolved')
|
||||
return resolved
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* @module @deepseek-ai/dsh/bin
|
||||
*/
|
||||
|
||||
/* v8 ignore file -- built-bin and PTY tests exercise this self-executing dispatch. */
|
||||
/* v8 ignore file -- built-bin acceptance exercises this self-executing dispatch. */
|
||||
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -25,10 +25,14 @@ function readVersion(): string {
|
||||
}
|
||||
|
||||
loadEnv('dsh')
|
||||
// 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')
|
||||
const invocation = parseDshArgs(process.argv.slice(2), readVersion())
|
||||
|
||||
switch (invocation.mode) {
|
||||
case 'config': {
|
||||
const { runConfig } = await import('./config.ts')
|
||||
await runConfig(invocation.config)
|
||||
break
|
||||
}
|
||||
case 'web': {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config)
|
||||
@@ -39,26 +43,11 @@ switch (invocation.mode) {
|
||||
await runHeadless(invocation.prompt)
|
||||
break
|
||||
}
|
||||
case 'tui': {
|
||||
const { runTui } = await import('./tui.ts')
|
||||
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:
|
||||
invocation satisfies never
|
||||
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
|
||||
|
||||
54
apps/cli/src/config.ts
Normal file
54
apps/cli/src/config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Raw `dsh --config <path>` boot: apply one required patch-list overlay over
|
||||
* the shipped base config, then leave process lifetime to the mounted plugins.
|
||||
* @module @deepseek-ai/dsh/config
|
||||
*/
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Context } from 'cordis'
|
||||
import {
|
||||
boot,
|
||||
installFailLoud,
|
||||
loadOverlayPatches,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { configHasTelemetryRow, resolveTelemetryPatch } from './app-cli-entry.ts'
|
||||
|
||||
const NAME = 'dsh'
|
||||
const BASE_CONFIG = fileURLToPath(new URL('../config/base.cordis.yml', import.meta.url))
|
||||
|
||||
/* v8 ignore start -- the source-launch and built-bin acceptance paths own executable dispatch */
|
||||
/**
|
||||
* Boot the shipped base with one explicit overlay.
|
||||
* @param config - required patch-list path parsed from `--config`.
|
||||
*/
|
||||
export async function runConfig(config: string): Promise<void> {
|
||||
const app: { current?: Context } = {}
|
||||
let exiting = false
|
||||
const shutdown = (code: number): void => {
|
||||
if (exiting) return
|
||||
exiting = true
|
||||
void Promise.resolve(app.current?.fiber.dispose()).finally(() => { process.exit(code) })
|
||||
}
|
||||
// An inserted front door can publish readiness before sibling rows finish
|
||||
// mounting. Signals must own teardown throughout that startup window, not
|
||||
// only after boot() settles.
|
||||
process.on('SIGTERM', () => { shutdown(0) })
|
||||
process.on('SIGINT', () => { shutdown(130) })
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
})
|
||||
const overlay = resolveConfigPath(config, undefined)
|
||||
const telemetryPatch = resolveTelemetryPatch(
|
||||
process.env.DSH_TELEMETRY_DISABLED,
|
||||
configHasTelemetryRow(BASE_CONFIG),
|
||||
)
|
||||
const ctx = await boot(NAME, BASE_CONFIG, [
|
||||
...loadOverlayPatches(NAME, overlay),
|
||||
...telemetryPatch === undefined ? [] : [telemetryPatch],
|
||||
], (hostCtx) => {
|
||||
app.current = hostCtx
|
||||
})
|
||||
app.current = ctx
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -1,12 +1,6 @@
|
||||
/**
|
||||
* `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.
|
||||
* Config-dump entry for raw `dsh --config` and `dsh web`: compose through the
|
||||
* include plugin's patch algorithm without booting or evaluating `!!js`.
|
||||
* @module @deepseek-ai/dsh/dump-config
|
||||
*/
|
||||
|
||||
@@ -22,39 +16,36 @@ import {
|
||||
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
|
||||
const WEB_OVERLAY = fileURLToPath(new URL('../config/web.cordis.yml', import.meta.url))
|
||||
|
||||
/* v8 ignore start -- composition over the unit-tested renderConfigDump; the
|
||||
built-bin e2e drives this path end to end */
|
||||
/* v8 ignore start -- built-bin acceptance drives this boot-free dispatch */
|
||||
/**
|
||||
* 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`.
|
||||
* Print a raw or Web composition with provenance comments.
|
||||
* @param surface - raw base-plus-config composition, or the Web composition.
|
||||
* @param defaultOnly - omit the explicit or personal user layer.
|
||||
* @param config - explicit overlay path; required for a non-default raw dump.
|
||||
*/
|
||||
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 {
|
||||
export function runDumpConfig(surface: 'config' | 'web', defaultOnly: boolean, config?: string): void {
|
||||
const layers: ConfigDumpLayer[] = []
|
||||
if (surface === 'config') {
|
||||
if (!defaultOnly) {
|
||||
/* v8 ignore next -- parseDshArgs requires this combination */
|
||||
if (config === undefined) throw new Error('dsh: raw config dump requires an overlay')
|
||||
layers.push({ label: config, patches: loadOverlayPatches(NAME, config) })
|
||||
}
|
||||
} else {
|
||||
layers.push({ label: basename(WEB_OVERLAY), patches: loadOverlayPatches(NAME, WEB_OVERLAY) })
|
||||
if (!defaultOnly) {
|
||||
if (config === undefined) {
|
||||
const personal = loadPersonalPatches(NAME)
|
||||
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))
|
||||
}
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
/**
|
||||
* 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>>
|
||||
@@ -1,49 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
@@ -1,385 +0,0 @@
|
||||
/**
|
||||
* 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -1,289 +0,0 @@
|
||||
/**
|
||||
* `dsh` default surface — the interactive TUI coding agent. Boots the shipped
|
||||
* 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: 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 { 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,
|
||||
loadOverlayPatches,
|
||||
loadPersonalPatches,
|
||||
resolveConfigPath,
|
||||
watchPersonalPatches,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { PatchOptions } from '@cordisjs/plugin-include'
|
||||
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 {
|
||||
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'
|
||||
|
||||
// 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.
|
||||
/** 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 CLI PTY smoke drives this path end to end, personal overlay included */
|
||||
/**
|
||||
* Run the interactive TUI from the invoking directory.
|
||||
* @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,
|
||||
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.
|
||||
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
||||
process.stderr.write(
|
||||
`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; use \`${NAME} -p "task"\` for pipes and automation\n`,
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
// 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
|
||||
// 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 } = {}
|
||||
// The Loader mounts entries concurrently, so `ui-tui` can already hold the
|
||||
// terminal (raw mode, bracketed paste, keyboard protocol) when something
|
||||
// else fails. A config-tree failure settles through `boot`, which disposes
|
||||
// the tree itself; this release covers the rejections `boot` cannot see — a
|
||||
// plugin's detached async work rejecting while mounting is still in flight
|
||||
// or after the tree settled. Disposing the tree runs the TUI's own shutdown,
|
||||
// which stops the terminal and hands the shell back; without it such a
|
||||
// failure returns to a corrupted prompt. `app.current` is captured from
|
||||
// boot's `prepare` hook, so it holds the root context for the whole mounting
|
||||
// window rather than only after boot resolves.
|
||||
installFailLoud(NAME, process, async () => {
|
||||
await app.current?.fiber.dispose()
|
||||
})
|
||||
// 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, cwd): Promise<never> {
|
||||
const current = app.current
|
||||
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
|
||||
const nextArgv = [
|
||||
process.execPath,
|
||||
...process.execArgv,
|
||||
entry,
|
||||
...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)
|
||||
throw new Error('process replacement returned unexpectedly')
|
||||
} catch (error) {
|
||||
process.stderr.write(`${NAME}: resume handoff failed after terminal release: ${String(error)}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
// 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 composePatches = (personalPatches: PatchOptions[]): PatchOptions[] => [
|
||||
...replaceTree ? [] : [
|
||||
...loadOverlayPatches(NAME, TUI_OVERLAY),
|
||||
...resolvedConfig === undefined
|
||||
? personalPatches
|
||||
: loadOverlayPatches(NAME, resolveConfigPath(resolvedConfig, undefined)),
|
||||
],
|
||||
...telemetryPatch === undefined ? [] : [telemetryPatch],
|
||||
]
|
||||
const patches = composePatches(loadPersonalPatches(NAME) ?? [])
|
||||
const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB)
|
||||
const ctx = await boot(
|
||||
NAME,
|
||||
bootConfig,
|
||||
patches,
|
||||
(hostCtx) => {
|
||||
// Runs after the Loader installs and before any config-tree entry mounts,
|
||||
// so the fail-loud release hook can reach the tree for the whole window in
|
||||
// which an entry may reject.
|
||||
app.current = hostCtx
|
||||
// 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)
|
||||
}
|
||||
},
|
||||
)
|
||||
// The shipped tree includes HMR and keeps personal config live. An explicit
|
||||
// --config tree replaces the personal overlay (so there is nothing to keep
|
||||
// live), and a --config-replace or HMR-less tree remains a valid composition
|
||||
// that still receives the startup overlay but deliberately has no hidden
|
||||
// watcher.
|
||||
if (resolvedConfig === undefined && !replaceTree && ctx.get('hmr') !== undefined) {
|
||||
await watchPersonalPatches(ctx, { binName: NAME, compose: composePatches })
|
||||
}
|
||||
app.current = ctx
|
||||
addHarnessSourceSection(ctx, SOURCE_ROOT)
|
||||
if (showFirstRunWelcome) {
|
||||
await ctx.plugin({
|
||||
name: tuiFirstRunWelcomeName,
|
||||
inject: tuiFirstRunWelcomeInject,
|
||||
apply: applyTuiFirstRunWelcome,
|
||||
}, {
|
||||
dshHome,
|
||||
asciiArt: needsTuiFirstRunWelcomeAsciiArt(),
|
||||
})
|
||||
}
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
@@ -14,7 +14,7 @@ import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
import type {} from '@deepseek-ai/dsh-tool-bash'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
|
||||
// The shared core every `dsh` surface mounts, plus this surface's overlay over it.
|
||||
// The shipped base plus the Web application's overlay.
|
||||
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))
|
||||
|
||||
Reference in New Issue
Block a user