mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
$DSH_HOME/.env carried two incompatible jobs. As credentials-local's writable secret store it could not be hoisted into process.env — hoisting makes every stored key read as a read-only launch override and blocks rotation from the TUI and the web page. But its name and dotenv format promise an environment file, so a DEEPSEEK_BASE_URL sitting beside a working DEEPSEEK_API_KEY in the same file was silently ignored: only the credential provider read the document, and it addresses credential references alone. Split the two jobs into two files. .credentials.yaml is the provider-managed store: a strict YAML mapping of CredentialRef to non-empty string, no version field, no wrapper level. Because it holds credentials and nothing else, a non-mapping root, a non-identifier key, a non-string value, an empty string, a duplicate key, and malformed YAML are all rejections rather than skipped entries — loud at boot and at a write, warn-and-keep-last-good on a live reload. The dotenv physical-line editor gives way to a patch of the parsed document, so comments and untouched entries keep their formatting and any string value round-trips, multi-line included. Writer lock, read-modify-write, atomic 0600 write under a 0700 directory, watcher, self-write suppression, and quiescent disposal are unchanged. $DSH_HOME/.env becomes the user's ordinary environment layer. app-boot's new loadLayeredEnv loads the invoking directory's .env then the Harness home's, giving user < project < inherited; the home resolves from the inherited environment first, so a project .env cannot redirect it. Credential precedence is unchanged: the live environment still wins read-only over the file, and shadowed writes still reject. Whether a provider-managed store should instead win over the environment is a separate decision. No migration: a key already in $DSH_HOME/.env keeps resolving through the new environment layer, as a read-only env source that shadows the stored one.
289 lines
14 KiB
TypeScript
289 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)
|
|
}
|
|
// The bin already loaded both environment files, and that is the whole
|
|
// environment: credentials live in `$DSH_HOME/.credentials.yaml`, which is
|
|
// never hoisted, so a stored key stays rotatable from the TUI and the web
|
|
// page. The environment is settled, so switching the workspace here cannot
|
|
// alter its precedence — the project layer is the *invoking* directory's. 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 */
|