/** * `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` * ({@link runMeta}) is the one exception — it makes this harness checkout the * workspace. `dsh upgrade` ({@link runSkillSession}) 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, } from '@deepseek-ai/dsh-app-boot' 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' 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. 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 with this harness checkout as the workspace * (`dsh meta`), whatever directory it was launched from. */ export async function runMeta(): Promise { return runTui(undefined, undefined, SOURCE_ROOT) } /** * Run the interactive TUI as a guided fresh session whose first turn invokes a * bundled skill (`dsh upgrade` → `dsh-upgrade`). * Always mints a fresh session in the invoking directory; the skill is seeded * only on this first launch, so a later `--resume` of the session is an ordinary * TUI session with no re-injection. * @param skill - the bundled skill name to auto-invoke as the first turn. */ export async function runSkillSession(skill: string): Promise { return runTui(undefined, undefined, undefined, skill) } /** * 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 {@link runSkillSession} 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 { // 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) } installFailLoud(NAME) // 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) 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 } = {} // 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 { 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 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, bootConfig, patches, (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) } }, ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) } /* v8 ignore stop */