274 lines
14 KiB
TypeScript
274 lines
14 KiB
TypeScript
/**
|
|
* `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)
|
|
}
|
|
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)
|
|
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 } = {}
|
|
// 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) => {
|
|
// 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 */
|