Retire the readline front door and the repl-agent example
Delete packages/ui/stdio and examples/repl-agent; rename stdio-demo to @deepseek-ai/dsh-tui-demo (TUI-only, refuses pipes before Loader boot). tui-agent owns the coding composition inline; echo-agent and the CI demo smoke move to the one-shot cli-demo bin, which gains -p/--prompt. The UI-independent with-key e2es move verbatim to tui-agent. SDK wizard's 'stdio' interface becomes 'tui'. PTY testing stays confined to TUI surfaces; all other subprocess tests ride pipes. See .agents/notes/implemented/simplification/2026-07-20-retire-readline-front-door.md
This commit is contained in:
@@ -5,10 +5,14 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ
|
||||
| Export | Role |
|
||||
|---|---|
|
||||
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
|
||||
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
|
||||
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
|
||||
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
|
||||
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
|
||||
| `boot(binName, absoluteConfigPath)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context |
|
||||
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
|
||||
| `boot(binName, absoluteConfigPath, patches?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context |
|
||||
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
|
||||
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
|
||||
|
||||
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection.
|
||||
|
||||
@@ -16,16 +20,27 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve
|
||||
|
||||
This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` launcher ([`sdk/scripts`](../../sdk/scripts/README.md), with the shared project model in [`sdk/helper`](../../sdk/helper/README.md)) owns process startup, tsx registration, and local-plugin source resolution, and consumes these helpers for the boot sequence itself.
|
||||
|
||||
## Personal config
|
||||
|
||||
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
|
||||
|
||||
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
|
||||
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
|
||||
|
||||
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
|
||||
|
||||
## Model Experience
|
||||
|
||||
Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application.
|
||||
Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application; the one export that contributes model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSection` places one short line near the system prompt's head, before per-request content, so it does not invalidate the cache across turns, and any other request-prefix change is owned by the named consumer.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals` or the Loader's optional native fallback; an in-process caller without either must use resolvable relative/file specifiers or tsx path mapping.
|
||||
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
|
||||
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
|
||||
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.
|
||||
- **Personal patches see only the booted file's own entries** — an overlay leaf that reaches its base through a nested include entry (the Code Mode configs) resolves personal patch ids against the overlay's top-level entries, not the included subtree.
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
/**
|
||||
* Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and
|
||||
* drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled.
|
||||
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
|
||||
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
|
||||
* against a leaf `cordis.yml` until the whole tree has settled.
|
||||
* @module @deepseek-ai/dsh-app-boot
|
||||
*/
|
||||
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { basename, dirname, resolve } from 'node:path'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { Context } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import Include from '@cordisjs/plugin-include'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
|
||||
import type {} from '@deepseek-ai/dsh-system-prompt'
|
||||
|
||||
/**
|
||||
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
|
||||
@@ -30,6 +36,48 @@ export function resolveConfigPath(
|
||||
return resolve(dir, replayName)
|
||||
}
|
||||
|
||||
/** CLI flag the interactive surface accepts to resume a persisted session by id. */
|
||||
const RESUME_FLAG = '--resume'
|
||||
|
||||
/**
|
||||
* Split a leading `--resume <id>` / `--resume=<id>` flag out of a CLI argument
|
||||
* vector, returning the resumed session id (when the flag is present) and the
|
||||
* remaining arguments with the flag and its value removed — so a positional
|
||||
* config path stays readable regardless of the flag's position. A `--resume`
|
||||
* with no following id, an empty id (`--resume=`), or a repeated `--resume`
|
||||
* throws: a mistyped resume must fail loud, never silently start a fresh
|
||||
* session. The id is not validated here; an unknown id fails loud downstream
|
||||
* when the session cannot load.
|
||||
* @param argv - the CLI arguments after subcommand dispatch.
|
||||
* @returns the parsed resume id (or `undefined`) and the flag-stripped arguments.
|
||||
*/
|
||||
export function parseResumeArg(
|
||||
argv: readonly string[],
|
||||
): { resumeSessionId: string | undefined; rest: string[] } {
|
||||
const rest: string[] = []
|
||||
let resumeSessionId: string | undefined
|
||||
let skipNext = false
|
||||
for (const [i, arg] of argv.entries()) {
|
||||
if (skipNext) {
|
||||
skipNext = false
|
||||
continue
|
||||
}
|
||||
const inlineValue = arg.startsWith(`${RESUME_FLAG}=`)
|
||||
if (arg === RESUME_FLAG || inlineValue) {
|
||||
if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`)
|
||||
const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1]
|
||||
if (value === undefined || value === '') {
|
||||
throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} <session-id>)`)
|
||||
}
|
||||
resumeSessionId = value
|
||||
skipNext = !inlineValue // the space form consumed the following token as its value
|
||||
continue
|
||||
}
|
||||
rest.push(arg)
|
||||
}
|
||||
return { resumeSessionId, rest }
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
|
||||
* ambient environment; other read failures are reported through `warn`.
|
||||
@@ -51,6 +99,62 @@ export function loadEnv(
|
||||
}
|
||||
}
|
||||
|
||||
/** File inside the Harness home holding the personal loader overlay patches. */
|
||||
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
|
||||
|
||||
// The include's YAML dialect: `!!js` scalars become expression nodes the
|
||||
// Loader interpolates against each entry's context at mount time. Personal
|
||||
// patches are parsed with the same schema so they may reference `process.env`.
|
||||
// Load-only: this schema never dumps, so no `predicate`/`represent`.
|
||||
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
|
||||
kind: 'scalar',
|
||||
resolve: data => typeof data === 'string',
|
||||
construct: data => ({ __jsExpr: String(data) }),
|
||||
})
|
||||
const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType)
|
||||
|
||||
/**
|
||||
* Load the optional personal overlay patches (`config.yaml` under the Harness
|
||||
* home). The file is a top-level YAML array of loader patch entries
|
||||
* (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides
|
||||
* and `insert` lists, with `!!js` expressions allowed. A missing file means
|
||||
* "no personal overlay"; an unreadable, unparsable, or non-array file throws —
|
||||
* a present personal config that cannot apply is a misconfiguration and must
|
||||
* fail loud at boot, never be silently skipped.
|
||||
* @param binName - the diagnostic prefix on the thrown error.
|
||||
* @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`).
|
||||
* @returns the parsed patches, or `undefined` when the file does not exist.
|
||||
*/
|
||||
export function loadPersonalPatches(
|
||||
binName: string, dir: string = resolveDshHome(),
|
||||
): PatchOptions[] | undefined {
|
||||
const file = join(dir, PERSONAL_CONFIG_FILENAME)
|
||||
let content: string
|
||||
try {
|
||||
content = readFileSync(file, 'utf8')
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
|
||||
throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
|
||||
}
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = yaml.load(content, { schema: personalPatchesSchema })
|
||||
} catch (error) {
|
||||
throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`)
|
||||
}
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`)
|
||||
}
|
||||
// A present personal config that cannot apply is a misconfiguration and must
|
||||
// fail loud here — the include only warns per entry at mount.
|
||||
parsed.forEach((entry, index) => {
|
||||
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
||||
throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
|
||||
}
|
||||
})
|
||||
return parsed as PatchOptions[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The slice of `process` {@link installFailLoud} needs — injectable so tests
|
||||
* exercise the handler without registering on (or exiting) the real process.
|
||||
@@ -109,18 +213,52 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
|
||||
* @param binName - the diagnostic prefix for load-failure errors.
|
||||
* @param absoluteConfigPath - the config to include; must already be absolute
|
||||
* (see {@link resolveConfigPath}).
|
||||
* @param patches - optional overlay patches applied over the included tree
|
||||
* (see {@link loadPersonalPatches}); an empty list mounts none.
|
||||
* @returns the root context once every entry has started.
|
||||
*/
|
||||
export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
|
||||
export async function boot(
|
||||
binName: string, absoluteConfigPath: string, patches?: PatchOptions[],
|
||||
): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.include = Include
|
||||
await ctx.loader.create({
|
||||
name: 'cordis:include',
|
||||
config: { path: pathToFileURL(absoluteConfigPath).href },
|
||||
config: {
|
||||
path: pathToFileURL(absoluteConfigPath).href,
|
||||
...patches !== undefined && patches.length > 0 ? { patches } : {},
|
||||
},
|
||||
})
|
||||
await ctx.loader.await()
|
||||
assertEntriesLoaded(ctx, binName)
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Prompt-section name for the harness-source location line an app bin adds after boot. */
|
||||
export const HARNESS_SOURCE_SECTION = 'harness:source'
|
||||
|
||||
/**
|
||||
* Add a global prompt section naming the on-disk path to the harness source
|
||||
* checkout the running bin was launched from, so the agent knows where its own
|
||||
* source lives (the self-referential `dsh-tool-cordis` toolset reads and edits
|
||||
* it). Call once on the settled boot context ({@link boot}); the section orders
|
||||
* just after the harness identity opener (`-100`) and before the deployment
|
||||
* persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
|
||||
* augment, so this is then a no-op that returns `undefined`. The section is
|
||||
* registered against the `systemPrompt` service's fiber, so a dev HMR reload of
|
||||
* that plugin drops it until the next boot.
|
||||
* @param ctx - the settled boot context whose global system prompt to augment.
|
||||
* @param sourceRoot - the absolute path to the harness checkout root.
|
||||
* @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted.
|
||||
*/
|
||||
export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined {
|
||||
const systemPrompt = ctx.get('systemPrompt')
|
||||
if (systemPrompt === undefined) return undefined
|
||||
return systemPrompt.section({
|
||||
name: HARNESS_SOURCE_SECTION,
|
||||
order: -99,
|
||||
text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`,
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user