Merge branch 'master' into worktree/dsh-arg-parser
Integrate the Commander argument adapter with master's safe session-resume feature and dsh web --dev flag. - args.ts: add --dev to the web parser. - tui.ts: keep master's process.execve in-place resume handoff, but take the adapter's parsed (config, resume); inject the resume id through boot's prepare(ctx) hook via ctx.provide(RESUME_SESSION_ID_KEY, id) instead of the RESUME_SESSION_ID env var; rebuild the re-exec argv as `dsh --resume <id>`. - app-boot: drop master's replaceResumeArg (no longer needed) alongside the already-removed parseResumeArg; add RESUME_SESSION_ID_KEY. - the four tui-agent/cordis configs read the ctx-provided resumeSessionId via a typeof-guarded !!js expression, so resume needs no env var. - web.ts: keep master's client roster and --dev watch, take parsed host/port/dev.
This commit is contained in:
@@ -7,12 +7,12 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad
|
||||
The TUI surface:
|
||||
|
||||
- boots the shipped default config (`examples/tui-agent/cordis.yml`) or an explicit config argument, through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
|
||||
- resumes a persisted session with `dsh --resume <session-id>` — the form the TUI prints on exit and lists under `/resume`; the flag sets `RESUME_SESSION_ID` before boot so the shipped config rehydrates that session, and a missing or unreadable id fails loud and exits nonzero;
|
||||
- resumes a persisted session with `dsh --resume <session-id>` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume flag; runtimes without process replacement keep the displayed command fallback, the flag still sets `RESUME_SESSION_ID` before boot, and a missing or unreadable id fails loud instead of creating a fresh session;
|
||||
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd;
|
||||
- tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it;
|
||||
- applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree.
|
||||
|
||||
The Web surface treats its invoking directory as the default project and loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget.
|
||||
The Web surface treats its invoking directory as the default project, loads applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opts into first-message model titles. The headless surface retains deterministic fallback titles without making the auxiliary title-model request.
|
||||
|
||||
## Install (developer machine)
|
||||
|
||||
@@ -22,4 +22,4 @@ Symlink the source-running launcher onto your PATH; it resolves the checkout thr
|
||||
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
|
||||
```
|
||||
|
||||
`pnpm run demo:tui` runs the same entry from the repo root. The built form (`lib/bin.js`, via `pnpm run build`) needs `node --expose-internals` for the shipped config's HMR entry, exactly like the demo bins.
|
||||
`pnpm run demo:tui` runs the same entry from the repo root. The built form (`lib/bin.js`, via `pnpm run build`) boots the same config under plain Node.
|
||||
|
||||
@@ -14,12 +14,24 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-app-boot": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-hmr": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-i18n": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-question": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
|
||||
"@deepseek-ai/dsh-frontend": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-apiproxy": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-webserver": "workspace:^",
|
||||
"@deepseek-ai/dsh-paths": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"commander": "^15.0.0"
|
||||
"@deepseek-ai/dsh-tui": "workspace:^",
|
||||
"commander": "^15.0.0",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,12 @@ interface HeadlessInvocation {
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/** Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}; port already coerced and range-checked. */
|
||||
/** Browser UI: `dsh web`. Host constrained to {@link LOOPBACK_HOST}/{@link ALL_INTERFACES_HOST}; port already coerced and range-checked; `dev` mounts the client HMR driver and bundle watch. */
|
||||
interface WebInvocation {
|
||||
mode: 'web'
|
||||
host: string
|
||||
port: number
|
||||
dev: boolean
|
||||
}
|
||||
|
||||
/** `--help` or `--version` requested: `bin.ts` prints `text` to stdout and exits 0. */
|
||||
@@ -108,10 +109,11 @@ function parseWeb(argv: readonly string[], version: string): DshInvocation {
|
||||
.description('serve the browser UI')
|
||||
.addOption(new Option('--host <host>', 'bind host').choices([LOOPBACK_HOST, ALL_INTERFACES_HOST]).default(LOOPBACK_HOST))
|
||||
.addOption(new Option('--port <port>', 'listen port').default(DEFAULT_WEB_PORT).argParser(parsePort))
|
||||
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
|
||||
const settled = settle(web, argv, sink)
|
||||
if (settled !== undefined) return settled
|
||||
const { host, port } = web.opts<{ host: string; port: number }>()
|
||||
return { mode: 'web', host, port }
|
||||
const { host, port, dev } = web.opts<{ host: string; port: number; dev?: boolean }>()
|
||||
return { mode: 'web', host, port, dev: dev ?? false }
|
||||
}
|
||||
|
||||
/** Parse the default (TUI / headless) arguments: `[config]`, `-p/--prompt`, `--resume`. */
|
||||
|
||||
@@ -32,7 +32,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion())
|
||||
switch (invocation.mode) {
|
||||
case 'web': {
|
||||
const { runWeb } = await import('./web.ts')
|
||||
await runWeb(invocation.host, invocation.port)
|
||||
await runWeb(invocation.host, invocation.port, invocation.dev)
|
||||
break
|
||||
}
|
||||
case 'headless': {
|
||||
|
||||
@@ -18,18 +18,15 @@ import {
|
||||
installFailLoud,
|
||||
loadEnv,
|
||||
loadPersonalPatches,
|
||||
RESUME_SESSION_ID_KEY,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import type { Context } from 'cordis'
|
||||
import type { TuiResumeHost } from '@deepseek-ai/dsh-tui'
|
||||
|
||||
const NAME = 'dsh'
|
||||
|
||||
// The env var the shipped tui-agent config reads (`resumeSessionId: !!js
|
||||
// process.env.RESUME_SESSION_ID`) to rehydrate a persisted session. The
|
||||
// `--resume <id>` flag is CLI sugar that sets it before boot, so the printed
|
||||
// `dsh --resume <id>` exit hint runs back through this same intake.
|
||||
const RESUME_SESSION_ID_ENV = 'RESUME_SESSION_ID'
|
||||
|
||||
// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit
|
||||
// one directory under apps/cli, so the shipped default config resolves with
|
||||
// the same relative hop from either artifact.
|
||||
@@ -47,7 +44,9 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
* @param config - a config path to boot instead of the shipped default, or
|
||||
* `undefined` for the default; already parsed from the optional positional.
|
||||
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
|
||||
* already parsed and non-empty-validated from `--resume`.
|
||||
* already parsed and non-empty-validated from `--resume`. It is provided on the
|
||||
* boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config
|
||||
* reads through `!!js` to rehydrate that session.
|
||||
*/
|
||||
export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise<void> {
|
||||
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree
|
||||
@@ -61,10 +60,48 @@ export async function runTui(config: string | undefined, resumeSessionId: string
|
||||
// The bin already loaded the invoking directory's .env; the personal .env
|
||||
// only fills what is still unset (process.loadEnvFile never overrides).
|
||||
loadEnv(NAME, resolveDshHome())
|
||||
// An explicit `--resume` flag beats any ambient RESUME_SESSION_ID, so set it
|
||||
// after loadEnv and before boot reads it through the config's `!!js`.
|
||||
if (resumeSessionId !== undefined) process.env[RESUME_SESSION_ID_ENV] = resumeSessionId
|
||||
const ctx = await boot(NAME, resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME))
|
||||
// The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume`
|
||||
// flag, so the resumed process rehydrates through this same intake. The host
|
||||
// is offered only when Node exposes `process.execve` and knows its own entry.
|
||||
const entry = process.argv[1]
|
||||
const execve = process.execve?.bind(process)
|
||||
const app: { current?: Context } = {}
|
||||
const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : {
|
||||
async handoff(sessionId): Promise<never> {
|
||||
const current = app.current
|
||||
if (current === undefined) throw new Error(`${NAME}: app boot has not completed`)
|
||||
// Rebuild argv from the parsed config plus the selected id: TUI mode's
|
||||
// only arguments are the optional config positional and `--resume <id>`.
|
||||
const nextArgv = [
|
||||
process.execPath,
|
||||
...process.execArgv,
|
||||
entry,
|
||||
...config !== undefined ? [config] : [],
|
||||
'--resume',
|
||||
sessionId,
|
||||
]
|
||||
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)
|
||||
}
|
||||
},
|
||||
}
|
||||
const ctx = await boot(
|
||||
NAME,
|
||||
resolveConfigPath(config ?? DEFAULT_CONFIG, undefined),
|
||||
loadPersonalPatches(NAME),
|
||||
(hostCtx) => {
|
||||
// Inject the resume id (or undefined) so the shipped config's `!!js`
|
||||
// reads it as a bare identifier; then offer the in-place handoff host.
|
||||
hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId)
|
||||
if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost)
|
||||
},
|
||||
)
|
||||
app.current = ctx
|
||||
addHarnessSourceSection(ctx, SOURCE_ROOT)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -10,30 +10,83 @@ import { mountWebPlugins, startHost } from '@deepseek-ai/dsh-host-runtime'
|
||||
import { createHostWebPluginRegistry, startWebServer } from '@deepseek-ai/dsh-host-webserver'
|
||||
import { ALL_INTERFACES_HOST, LOOPBACK_HOST } from './args.ts'
|
||||
|
||||
// --- Client composition (composition decisions live in the composing app) ---
|
||||
// The composition layer owns one decision: which plugin packages mount (the
|
||||
// roster). Dependency edges and the boot prefetch tier live in each package's
|
||||
// dshClient declaration.
|
||||
|
||||
/**
|
||||
* Serve the browser UI. Host and port are already validated by the argument
|
||||
* adapter (host constrained to loopback/all-interfaces, port a 0–65535 integer).
|
||||
* Dev-only plugin: the client HMR driver. Whether it composes in is a
|
||||
* deployment decision — the dev graph includes its row, the prod graph does
|
||||
* not mount it at all.
|
||||
*/
|
||||
const CLIENT_HMR_ID = '@deepseek-ai/dsh-client-hmr'
|
||||
|
||||
/** Bundle stat-poll interval for --dev (held here so the startup log states the real value). */
|
||||
const CLIENT_BUNDLE_POLL_MS = 500
|
||||
|
||||
/** The client plugin roster (flat; per-row boot behavior comes from manifests). */
|
||||
const CLIENT_PACKAGES = [
|
||||
'@deepseek-ai/dsh-client-connection',
|
||||
'@deepseek-ai/dsh-client-runtime',
|
||||
'@deepseek-ai/dsh-client-ui-theme',
|
||||
'@deepseek-ai/dsh-client-i18n',
|
||||
'@deepseek-ai/dsh-client-ui-layout',
|
||||
'@deepseek-ai/dsh-client-ui-sidebar',
|
||||
'@deepseek-ai/dsh-client-ui-conversation',
|
||||
'@deepseek-ai/dsh-client-ui-question',
|
||||
'@deepseek-ai/dsh-client-ui-trajectory',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Serve the browser UI. Host, port, and dev are already validated by the
|
||||
* argument adapter (host constrained to loopback/all-interfaces, port a
|
||||
* 0–65535 integer).
|
||||
* @param hostAddress - the bind host: {@link LOOPBACK_HOST} or {@link ALL_INTERFACES_HOST}.
|
||||
* @param port - the listen port; `0` lets the OS choose a free port.
|
||||
* @param dev - mount the client HMR driver and watch plugin bundles for rebuilds.
|
||||
*/
|
||||
export async function runWeb(hostAddress: string, port: number): Promise<void> {
|
||||
export async function runWeb(hostAddress: string, port: number, dev: boolean): Promise<void> {
|
||||
// A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design).
|
||||
const host = await startHost({
|
||||
boot: {
|
||||
persistenceRoot: './.sessions',
|
||||
workspaceContext: { maxBytes: 65_536 },
|
||||
sessionTitleLlm: true,
|
||||
},
|
||||
})
|
||||
|
||||
// Web UI plugin chain: in-memory Loader tree over the eight UI packages,
|
||||
// then the registry that feeds __DSH_BOOT__ and /plugins/<id>/client.js.
|
||||
const mounted = await mountWebPlugins(host.ctx)
|
||||
// Client plugin chain: in-memory Loader tree over the composed roster, then
|
||||
// the registry that feeds the __DSH_BOOT__ entry graph and
|
||||
// /plugins/<id>/client.js. All row content comes from dshClient discovery
|
||||
// over the mounted roster (dev adds the HMR driver row and turns on the
|
||||
// bundle watch that drives rebuilt frames).
|
||||
const roster = [...CLIENT_PACKAGES, ...dev ? [CLIENT_HMR_ID] : []]
|
||||
const mounted = await mountWebPlugins(host.ctx, roster, import.meta.url)
|
||||
const webPlugins = createHostWebPluginRegistry({
|
||||
ctx: host.ctx,
|
||||
loader: mounted.loader,
|
||||
resolvePkgJson: mounted.resolvePkgJson,
|
||||
onError: (err: Error) => { process.stderr.write(`dsh web: plugin rescan: ${String(err)}\n`) },
|
||||
...dev ? { watch: { intervalMs: CLIENT_BUNDLE_POLL_MS } } : {},
|
||||
})
|
||||
if (dev) {
|
||||
// Dev visibility (the registry is a library and never prints): list what
|
||||
// the bundle watch covers, then log every observed rebuild. This is a
|
||||
// second onRebuilt subscription — the SSE relay inside the webserver is
|
||||
// unaffected (multicast).
|
||||
const revs = new Map(webPlugins.graph().entries.map(row => [row.id, row.rev]))
|
||||
const bundlePaths = [...revs.keys()]
|
||||
.map(id => webPlugins.clientPath(id))
|
||||
.filter((path): path is string => path !== undefined)
|
||||
console.log(
|
||||
`dsh web: watching ${String(bundlePaths.length)} plugin bundles (${String(CLIENT_BUNDLE_POLL_MS)}ms poll):\n ${bundlePaths.join('\n ')}`,
|
||||
)
|
||||
webPlugins.onRebuilt((id, rev) => {
|
||||
console.log(`dsh web: plugin rebuilt: ${id} rev ${revs.get(id) ?? '?'} -> ${rev}`)
|
||||
revs.set(id, rev)
|
||||
})
|
||||
}
|
||||
// Published so the webserver invariant companion can audit manifest/bundle
|
||||
// consistency; nothing else reads this key.
|
||||
host.ctx.reflect.provide('webPlugins', webPlugins)
|
||||
|
||||
@@ -9,9 +9,10 @@ describe('parseDshArgs', () => {
|
||||
expect(parse(['custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
|
||||
expect(parse(['--resume', 'sess', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
|
||||
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080 })
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: false })
|
||||
expect(parse(['web', '--host', ALL_INTERFACES_HOST, '--port', '8080']))
|
||||
.toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080 })
|
||||
.toEqual({ mode: 'web', host: ALL_INTERFACES_HOST, port: 8080, dev: false })
|
||||
expect(parse(['web', '--dev'])).toEqual({ mode: 'web', host: LOOPBACK_HOST, port: 3080, dev: true })
|
||||
})
|
||||
|
||||
it('fails loud instead of silently starting fresh or serving on bad input', () => {
|
||||
|
||||
@@ -8,12 +8,59 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../vendor/cordis" },
|
||||
{ "path": "../../packages/host/apiproxy" },
|
||||
{ "path": "../../packages/host/runtime" },
|
||||
{ "path": "../../packages/host/webserver" },
|
||||
{ "path": "../../packages/core/session" },
|
||||
{ "path": "../../packages/ui/app-boot" },
|
||||
{ "path": "../../packages/util/paths" }
|
||||
{
|
||||
"path": "../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/apiproxy"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/host/webserver"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/ui/app-boot"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/ui/tui"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/connection"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/hmr"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/runtime"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-theme"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/i18n"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-layout"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-sidebar"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-trajectory"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/ui-question"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user