Merge origin/master: web permission sandbox, default pi-ai providers
This commit is contained in:
@@ -2,18 +2,24 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI.
|
||||
The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, `dsh meta` boots that same TUI over this harness checkout, `dsh migrate` and `dsh upgrade` boot a fresh guided TUI session whose first turn invokes a bundled skill, `dsh list-sessions` lists the sessions running right now, and `dsh web` serves the browser UI.
|
||||
|
||||
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
|
||||
Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, whose `list-sessions` subcommand (alias `ps`) lists live sessions, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `list-sessions`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags.
|
||||
|
||||
The TUI surface:
|
||||
|
||||
- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config <path>` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md);
|
||||
- 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 `dsh --resume <id>`; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, 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;
|
||||
- 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 invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, 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 (`dsh meta` is the sole exception, below);
|
||||
- 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.
|
||||
|
||||
`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd, the `./.sessions` persistence root, and the HMR watch root all move together. It accepts only `--resume <id>`; `--config` (which would boot a foreign tree) and `-p` (which is not interactive) fail loud. Because meta sessions live under the checkout, `--resume` here sees only other meta sessions, and both the in-place handoff and the printed exit line reproduce the mode as `dsh meta --resume <id>`, so a copied command resumes the right session from any directory.
|
||||
|
||||
`dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:<name>`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
|
||||
|
||||
`dsh list-sessions` lists the sessions running right now: session id, pid, uptime, workspace, and title, newest first. It is read-only and boots no agent tree — it mounts the [session registry](../../packages/session-registry/session-registry/README.md) alone, so listing is fast and cannot start model work as a side effect. Every surface publishes its sessions into that registry through [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md), and records whose process is gone are pruned on read, so a crashed session disappears without cleanup. `--json` emits the same records as a machine-readable array; an empty listing prints one line and exits 0. There is no workspace filter: the listing is always every live session, whatever directory it runs in. Only top-level surfaces appear — subagents share or spawn other processes and are deliberately invisible.
|
||||
|
||||
The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root <path>` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`).
|
||||
|
||||
The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment.
|
||||
|
||||
@@ -2,18 +2,24 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。
|
||||
`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh meta` 以本 harness checkout 为 workspace 启动同一个 TUI,`dsh migrate` 和 `dsh upgrade` 启动一个全新的引导式 TUI 会话并在首轮调用内置 skill,`dsh list-sessions` 列出此刻正在运行的会话,`dsh web` 则提供浏览器 UI。
|
||||
|
||||
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
|
||||
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`list-sessions` 子命令(别名 `ps`)列出存活会话,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`list-sessions`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
|
||||
|
||||
TUI 界面:
|
||||
|
||||
- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config <path>` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动;
|
||||
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume <id>` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话;
|
||||
- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析;
|
||||
- 使用 `dsh --resume <session-id>` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话;
|
||||
- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文);
|
||||
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
|
||||
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。
|
||||
|
||||
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd、`./.sessions` 持久化根目录与 HMR 监视根目录会一并移动。它只接受 `--resume <id>`;`--config`(会启动其他配置树)和 `-p`(非交互)都会明确报错。由于 meta 会话位于该 checkout 之下,此处的 `--resume` 只能看到其他 meta 会话;原地移交与打印的退出行都会以 `dsh meta --resume <id>` 复现该 mode,因此复制的命令在任何目录下都能恢复到正确的会话。
|
||||
|
||||
`dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
|
||||
|
||||
`dsh list-sessions` 列出此刻正在运行的会话:会话 id、pid、运行时长、工作区和标题,最新的在前。它是只读的,不启动任何 agent 树——它只挂载[会话注册表](../../packages/session-registry/session-registry/README.md),因此列表既快,也不会作为副作用启动模型工作。每个界面都通过 [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md) 把自己的会话发布到该注册表,进程已不存在的记录会在读取时被剪除,因此崩溃的会话无需清理便会消失。`--json` 以机器可读的数组形式输出同样的记录;空列表打印一行并以 0 退出。没有工作区过滤:列表始终是全部存活会话,无论它们运行在哪个目录下。只有顶层界面会出现——subagent 共用别的进程,或 spawn 出别的进程,因此被刻意排除在列表之外。
|
||||
|
||||
Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root <path>` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。
|
||||
|
||||
已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。
|
||||
|
||||
@@ -70,6 +70,9 @@
|
||||
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-registry": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-registry-file": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-registry-live": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
|
||||
@@ -18,7 +18,7 @@ import Loader from '@cordisjs/plugin-loader'
|
||||
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
|
||||
import yaml from 'js-yaml'
|
||||
import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths'
|
||||
// Empty type import carries the httpServer Context merge for the port read below.
|
||||
import type {} from '@deepseek-ai/dsh-host-webserver'
|
||||
|
||||
@@ -178,11 +178,11 @@ export class AppCLIEntry {
|
||||
overrides.set(entryId, bag)
|
||||
}
|
||||
|
||||
// Source 0: computed engineering defaults. The session store defaults to
|
||||
// a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history
|
||||
// is shared across every cwd, not a project-local ./.sessions. The profile
|
||||
// Source 0: computed engineering defaults. The session store is the one
|
||||
// shared root every dsh surface resolves, so history follows the user across
|
||||
// working directories instead of splitting per project. The profile
|
||||
// (Source 1) overwrites this same field via last-write-wins in put().
|
||||
put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions'))
|
||||
put('session-persistence-jsonl', 'root', resolveSessionsRoot())
|
||||
|
||||
// Source 1: profile json (missing file = empty; unmapped key = loud).
|
||||
for (const [key, value] of Object.entries(this.readProfile())) {
|
||||
@@ -209,6 +209,7 @@ export class AppCLIEntry {
|
||||
// user config. Workspace knowledge stays here.
|
||||
put('webserver', 'distIndex', this.resolveDistIndex())
|
||||
|
||||
|
||||
this.patches = [...overrides.entries()].map(([id, bag]) => {
|
||||
const yml = rows.get(id)
|
||||
if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`)
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
* Commander adapter for the `dsh` command-line entry: the one place argv is
|
||||
* parsed and routed to a mode. `bin.ts` switches on the returned discriminant
|
||||
* and dynamic-imports that mode's module. One program: the default (no
|
||||
* subcommand) is the TUI/headless surface with option-only flags; `web` is a
|
||||
* real subcommand. Commander owns `--help`/`--version` and parse errors — it
|
||||
* prints and exits at the point of failure (a domain failure routes through
|
||||
* subcommand) is the TUI/headless surface with option-only flags; `meta` and
|
||||
* `web` are real subcommands. Commander owns `--help`/`--version` and parse
|
||||
* errors — it prints and exits at the point of failure (a domain failure routes through
|
||||
* `command.error`), so this returns only a resolved mode.
|
||||
* @module @deepseek-ai/dsh/args
|
||||
*/
|
||||
@@ -24,6 +24,39 @@ interface HeadlessInvocation {
|
||||
prompt: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive TUI over this harness checkout: `dsh meta`. Identical to
|
||||
* {@link TuiInvocation} except the workspace is the launcher's own source tree
|
||||
* rather than the invoking directory. No `--config`: booting a foreign tree
|
||||
* against the harness workspace is the `--config` case, not this one.
|
||||
*/
|
||||
interface MetaInvocation {
|
||||
mode: 'meta'
|
||||
resume?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Guided fresh-session entries: `dsh migrate` seeds the first turn with the
|
||||
* `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a
|
||||
* fresh session in the invoking directory and takes no options — `--resume`,
|
||||
* `--config`, and `-p` are rejected as mistyped, so there is nothing to carry.
|
||||
*/
|
||||
interface SkillSessionInvocation {
|
||||
mode: 'migrate' | 'upgrade'
|
||||
}
|
||||
|
||||
/**
|
||||
* List live sessions: `dsh list-sessions` (alias `dsh ps`). A read-only surface
|
||||
* that boots no agent tree — it reads the cross-process session registry and
|
||||
* exits. `json` selects the machine-readable form over the human table. There
|
||||
* is no workspace filter: the listing is always every live session, whatever
|
||||
* directory it runs in.
|
||||
*/
|
||||
interface ListSessionsInvocation {
|
||||
mode: 'list-sessions'
|
||||
json: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Browser UI: `dsh web`. `host`/`port` are present only when the flag was
|
||||
* passed — pass-through overrides with no CLI default and no CLI validation:
|
||||
@@ -45,7 +78,13 @@ interface WebInvocation {
|
||||
}
|
||||
|
||||
/** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */
|
||||
export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation
|
||||
export type DshInvocation =
|
||||
| TuiInvocation
|
||||
| HeadlessInvocation
|
||||
| MetaInvocation
|
||||
| SkillSessionInvocation
|
||||
| ListSessionsInvocation
|
||||
| WebInvocation
|
||||
|
||||
/** Raw web-subcommand options straight from Commander. */
|
||||
interface WebOptions {
|
||||
@@ -86,13 +125,22 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
|
||||
const program = new Command()
|
||||
.name('dsh')
|
||||
.version(version, '-V, --version', 'output the version number')
|
||||
.description('dsh: interactive TUI (default), headless task, and browser UI')
|
||||
.description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.')
|
||||
// The default surface takes no positional task, so `dsh "task"` fails
|
||||
// commander's arity check with no hint; these examples are where a first
|
||||
// reader learns the entry points and that a one-shot task rides `-p`.
|
||||
.addHelpText('after', `
|
||||
Examples:
|
||||
dsh start an interactive session in this directory
|
||||
dsh -p "run the tests" answer one task, print the result, and exit
|
||||
dsh --resume <id> continue a past session (list ids with \`dsh ps\`)
|
||||
`)
|
||||
.exitOverride()
|
||||
// Default surface: option-only (no positional), so `web` can be a real
|
||||
// subcommand without a positional collision.
|
||||
.option('--config <path>', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)')
|
||||
.option('-p, --prompt <task>', 'run one headless turn for this task, print the result, and exit')
|
||||
.option('--resume <id>', 'resume the persisted session with this id (TUI mode)')
|
||||
.option('-p, --prompt <task>', 'answer this task without the interactive UI, then exit')
|
||||
.option('--resume <id>', 'continue a past session by id (list ids with `dsh ps`)')
|
||||
.option('--config <path>', 'start with an alternate plugin configuration file')
|
||||
.action((options: { config?: string; prompt?: string; resume?: string }) => {
|
||||
if (options.prompt !== undefined) {
|
||||
// A headless prompt owns the invocation; an empty task has nothing to
|
||||
@@ -115,25 +163,84 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc
|
||||
}
|
||||
})
|
||||
|
||||
const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)')
|
||||
// Commander parses the parent (default-surface) options on either side of a
|
||||
// subcommand into `program.opts()`. For a subcommand that shares none of them,
|
||||
// a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail
|
||||
// loud rather than silently run and drop the input.
|
||||
const rejectParentOptions = (command: string): void => {
|
||||
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
|
||||
if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
|
||||
program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`)
|
||||
}
|
||||
}
|
||||
|
||||
// Registration order is the rendered help order, so daily use comes first
|
||||
// and the harness-development surfaces (`web --dev`, `meta`) come last.
|
||||
// `migrate` and `upgrade` are guided fresh-session entries: they take no
|
||||
// options and always mint a fresh session, so nothing is left to carry. Each
|
||||
// description names the outcome, not the skill the first turn invokes.
|
||||
const guided = {
|
||||
migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)',
|
||||
upgrade: 'update this dsh installation to the latest version',
|
||||
} as const
|
||||
for (const mode of ['migrate', 'upgrade'] as const) {
|
||||
program
|
||||
.command(mode)
|
||||
.description(guided[mode])
|
||||
.action(() => {
|
||||
rejectParentOptions(mode)
|
||||
resolved = { mode }
|
||||
})
|
||||
}
|
||||
|
||||
program
|
||||
.command('list-sessions')
|
||||
.alias('ps')
|
||||
.description('list sessions running right now')
|
||||
.option('--json', 'print the records as a JSON array instead of a table')
|
||||
.action((options: { json?: boolean }) => {
|
||||
rejectParentOptions('list-sessions')
|
||||
resolved = { mode: 'list-sessions', json: options.json === true }
|
||||
})
|
||||
|
||||
// Host and port name no default: the CLI passes neither through when the flag
|
||||
// is absent, so the shipped `cordis.yml` value stands and restating it here
|
||||
// would duplicate a fact this file does not own.
|
||||
const web = program.command('web').description('serve the browser UI on the configured host and port')
|
||||
web
|
||||
.option('--host <host>', 'override the config bind host (127.0.0.1 or 0.0.0.0)')
|
||||
.option('--port <port>', 'override the config listen port (0 requests an OS-assigned port)')
|
||||
.option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds')
|
||||
.option('--workspace-root <path>', 'parent directory for name-created workspaces')
|
||||
.option('--host <host>', 'bind host; pass 0.0.0.0 to reach it from another machine')
|
||||
.option('--port <port>', 'listen port; pass 0 to let the OS pick a free one')
|
||||
.option('--dev', 'developer mode: hot-reload the browser client')
|
||||
.option('--workspace-root <path>', 'parent directory for workspaces created from the browser UI')
|
||||
.option('--trusted-host <authority...>', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)')
|
||||
.action((options: WebOptions) => {
|
||||
// Commander parses the parent (default-surface) options on either side of
|
||||
// the subcommand into `program.opts()`. `web` shares none of them, so a
|
||||
// leaked `--config`/`-p`/`--resume` is a mistyped invocation that must
|
||||
// fail loud rather than silently start the web server and drop it.
|
||||
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
|
||||
if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) {
|
||||
program.error('error: web takes none of --config, -p/--prompt, or --resume')
|
||||
}
|
||||
rejectParentOptions('web')
|
||||
resolved = resolveWeb(options)
|
||||
})
|
||||
|
||||
// `--resume` is NOT redeclared here: an option a subcommand shares with its
|
||||
// parent parses into `program.opts()` and leaves the subcommand's own options
|
||||
// empty, so redeclaring it would silently drop the id. Commander therefore
|
||||
// omits it from this subcommand's option list, hence the trailing help text.
|
||||
program
|
||||
.command('meta')
|
||||
.description('work on the dsh source that runs this command, from any directory')
|
||||
.addHelpText('after', '\nAccepts --resume <id> to resume a persisted session from this checkout.\n')
|
||||
.action(() => {
|
||||
// Commander parses the parent (default-surface) options on either side of
|
||||
// the subcommand into `program.opts()`. `meta` accepts only `--resume`, so
|
||||
// a leaked `--config`/`-p` is a mistyped invocation that must fail loud
|
||||
// rather than silently be dropped.
|
||||
const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>()
|
||||
if (parent.config !== undefined || parent.prompt !== undefined) {
|
||||
program.error('error: meta takes neither --config nor -p/--prompt')
|
||||
}
|
||||
// Same reason as the default surface: an empty id would start a fresh
|
||||
// session downstream instead of failing the mistyped resume.
|
||||
if (parent.resume === '') program.error('error: --resume needs a session id')
|
||||
resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } }
|
||||
})
|
||||
|
||||
try {
|
||||
program.parse(argv, { from: 'user' })
|
||||
} catch (error) {
|
||||
|
||||
@@ -43,6 +43,22 @@ switch (invocation.mode) {
|
||||
await runTui(invocation.config, invocation.resume)
|
||||
break
|
||||
}
|
||||
case 'meta': {
|
||||
const { runMeta } = await import('./tui.ts')
|
||||
await runMeta(invocation.resume)
|
||||
break
|
||||
}
|
||||
case 'list-sessions': {
|
||||
const { runListSessions } = await import('./list-sessions.ts')
|
||||
await runListSessions(invocation.json)
|
||||
break
|
||||
}
|
||||
case 'migrate':
|
||||
case 'upgrade': {
|
||||
const { runSkillSession } = await import('./tui.ts')
|
||||
await runSkillSession(`dsh-${invocation.mode}`)
|
||||
break
|
||||
}
|
||||
default:
|
||||
invocation satisfies never
|
||||
throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`)
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api'
|
||||
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
import { registerLiveSessions } from './register-session.ts'
|
||||
|
||||
/** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */
|
||||
interface TurnOutcome {
|
||||
@@ -80,6 +81,7 @@ export async function runHeadless(task: string): Promise<void> {
|
||||
port: 0,
|
||||
})
|
||||
const { ctx, port } = await entry.run()
|
||||
await registerLiveSessions(ctx)
|
||||
const dispose = async (): Promise<void> => { await ctx.fiber.dispose() }
|
||||
// The headless session is web-observable while it runs (same composition).
|
||||
process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`)
|
||||
|
||||
101
apps/cli/src/list-sessions.ts
Normal file
101
apps/cli/src/list-sessions.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* `dsh list-sessions` (alias `dsh ps`) — list the sessions running right now.
|
||||
*
|
||||
* A read-only surface: it mounts the session registry alone and never boots an
|
||||
* agent tree, so listing stays fast and cannot start model work as a side
|
||||
* effect. Liveness comes from the registry, which prunes records whose process
|
||||
* is gone, and every displayed field including the title comes from the record,
|
||||
* so no session log is opened and no backend format is assumed.
|
||||
* @module @deepseek-ai/dsh/list-sessions
|
||||
*/
|
||||
|
||||
import { Context } from 'cordis'
|
||||
import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
|
||||
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
|
||||
import { registryRoot } from './register-session.ts'
|
||||
|
||||
/** Column header text, also the minimum width of each column. */
|
||||
const HEADERS = ['SESSION', 'PID', 'UPTIME', 'WORKSPACE', 'TITLE'] as const
|
||||
|
||||
/** Shown when a session has no title yet. */
|
||||
const NO_TITLE = '—'
|
||||
|
||||
/**
|
||||
* Render milliseconds of uptime as a compact human duration.
|
||||
* @param ms - elapsed milliseconds since the session registered.
|
||||
* @returns a short duration such as `12s`, `4m`, or `2h14m`.
|
||||
*/
|
||||
export function formatUptime(ms: number): string {
|
||||
const seconds = Math.max(0, Math.floor(ms / 1000))
|
||||
if (seconds < 60) return `${String(seconds)}s`
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
if (minutes < 60) return `${String(minutes)}m`
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainder = minutes % 60
|
||||
if (hours < 24) return remainder === 0 ? `${String(hours)}h` : `${String(hours)}h${String(remainder)}m`
|
||||
const days = Math.floor(hours / 24)
|
||||
const leftoverHours = hours % 24
|
||||
return leftoverHours === 0 ? `${String(days)}d` : `${String(days)}d${String(leftoverHours)}h`
|
||||
}
|
||||
|
||||
/** One fully-resolved listing row, in column order. */
|
||||
type Row = readonly [string, string, string, string, string]
|
||||
|
||||
/**
|
||||
* Build the display rows for a listing, newest session first.
|
||||
* @param records - the live records to render.
|
||||
* @param now - the current epoch milliseconds uptime is measured against.
|
||||
* @returns one row per record, each already stringified per column.
|
||||
*/
|
||||
export function buildRows(records: readonly SessionRegistryRecord[], now: number): Row[] {
|
||||
return [...records]
|
||||
.sort((left, right) => right.startedAt - left.startedAt)
|
||||
.map(record => [
|
||||
record.sessionId,
|
||||
String(record.pid),
|
||||
formatUptime(now - record.startedAt),
|
||||
record.cwd,
|
||||
record.title ?? NO_TITLE,
|
||||
] as const)
|
||||
}
|
||||
|
||||
/**
|
||||
* Render rows as a left-aligned table with a header line.
|
||||
*
|
||||
* The last column is never padded, so a long title cannot add trailing
|
||||
* whitespace to every line.
|
||||
* @param rows - the rows to render, already stringified.
|
||||
* @returns the complete table text, newline-terminated.
|
||||
*/
|
||||
export function renderTable(rows: readonly Row[]): string {
|
||||
const widths = HEADERS.map((header, column) =>
|
||||
Math.max(header.length, ...rows.map(row => row[column]?.length ?? 0)))
|
||||
const line = (cells: readonly string[]): string =>
|
||||
cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd()
|
||||
return [line(HEADERS), ...rows.map(row => line(row))].join('\n') + '\n'
|
||||
}
|
||||
|
||||
/**
|
||||
* List live sessions and exit. Prints a table by default, or a JSON array with
|
||||
* `--json`; an empty listing is a success, not an error.
|
||||
* @param json - emit the machine-readable JSON array instead of the table.
|
||||
*/
|
||||
export async function runListSessions(json: boolean): Promise<void> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionRegistryFile, { root: registryRoot() })
|
||||
const records = await ctx.sessionRegistry.list()
|
||||
await ctx.fiber.dispose()
|
||||
|
||||
if (json) {
|
||||
const rows = [...records]
|
||||
.sort((left, right) => right.startedAt - left.startedAt)
|
||||
.map(record => ({ ...record, uptimeMs: Date.now() - record.startedAt, title: record.title ?? null }))
|
||||
process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`)
|
||||
return
|
||||
}
|
||||
if (records.length === 0) {
|
||||
process.stdout.write('no dsh sessions running\n')
|
||||
return
|
||||
}
|
||||
process.stdout.write(renderTable(buildRows(records, Date.now())))
|
||||
}
|
||||
44
apps/cli/src/register-session.ts
Normal file
44
apps/cli/src/register-session.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Mounts the cross-process live-session registry that `dsh list-sessions` reads, plus the
|
||||
* publisher that keeps it in step with this process's sessions.
|
||||
*
|
||||
* Both plugins mount on the booted app's own context, so records share that
|
||||
* fiber's lifetime: an ordinary exit disposes the fiber and deregisters, while a
|
||||
* killed process leaves records the next reader prunes by pid. Only top-level
|
||||
* surfaces a user launches mount this — in-process subagents have no process of
|
||||
* their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent`
|
||||
* rather than this CLI, so neither reaches this path.
|
||||
* @module @deepseek-ai/dsh/register-session
|
||||
*/
|
||||
|
||||
import { join } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file'
|
||||
import * as sessionRegistryLive from '@deepseek-ai/dsh-session-registry-live'
|
||||
|
||||
/** Registry root under the Harness home, shared by every surface and by `dsh list-sessions`. */
|
||||
export const registryRoot = (): string => join(resolveDshHome(), 'run')
|
||||
|
||||
/**
|
||||
* Publish this process's sessions for the lifetime of `ctx`.
|
||||
*
|
||||
* Publication follows session lifecycle rather than a launcher-known id, so one
|
||||
* path serves every surface identically — the TUI's single session and a
|
||||
* server's on-demand ones alike — and titles reach the listing as they are
|
||||
* logged.
|
||||
*
|
||||
* Mounting is best-effort: a registry failure must not take down a working agent
|
||||
* session, because the registry is an observability aid rather than part of the
|
||||
* agent's contract. Failures warn through the context logger.
|
||||
* @param ctx - the booted app context whose lifetime the records share.
|
||||
*/
|
||||
export async function registerLiveSessions(ctx: Context): Promise<void> {
|
||||
try {
|
||||
const scope = ctx.isolate('sessionRegistry')
|
||||
await scope.plugin(SessionRegistryFile, { root: registryRoot() })
|
||||
await scope.plugin(sessionRegistryLive)
|
||||
} catch (error) {
|
||||
ctx.logger('dsh').warn('session registry unavailable; `dsh list-sessions` will not list these sessions: %s', String(error))
|
||||
}
|
||||
}
|
||||
@@ -4,13 +4,20 @@
|
||||
* 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: sessions, relative paths, and workspace instructions resolve from
|
||||
* the cwd, so `dsh` acts on whatever project it is launched in. After boot, the
|
||||
* agent's system prompt is told the path to this harness checkout so it can find
|
||||
* its own source.
|
||||
* 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 migrate`/`dsh upgrade` ({@link runSkillSession}) are fresh
|
||||
* sessions 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 { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import {
|
||||
@@ -19,13 +26,18 @@ import {
|
||||
installFailLoud,
|
||||
loadEnv,
|
||||
loadPersonalPatches,
|
||||
RESUME_SESSION_ID_KEY,
|
||||
resolveConfigPath,
|
||||
} from '@deepseek-ai/dsh-app-boot'
|
||||
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
|
||||
import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { Context } from 'cordis'
|
||||
import { registerLiveSessions } from './register-session.ts'
|
||||
import {
|
||||
INITIAL_SKILL_KEY,
|
||||
MAIN_SESSION_ID_KEY,
|
||||
SESSIONS_ROOT_KEY,
|
||||
TUI_GOODBYE_MESSAGE_KEY,
|
||||
type MainSessionIdentity,
|
||||
type TuiResumeHost,
|
||||
} from '@deepseek-ai/dsh-tui'
|
||||
|
||||
@@ -41,18 +53,65 @@ const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis
|
||||
// symlink, an arbitrary cwd). The agent is told where its own source lives.
|
||||
const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
|
||||
|
||||
/**
|
||||
* The value `dsh` provides on the {@link SESSIONS_ROOT_KEY} boot slot: its
|
||||
* shared session-store root, `sessions` under the Harness home. Shared-store
|
||||
* policy is the launcher's alone — the app bundle treats the slot as opaque and
|
||||
* keeps a project-local fallback, so only `dsh` decides that sessions are
|
||||
* shared across working directories (making `/resume` and `list-sessions` span
|
||||
* every workspace).
|
||||
* @returns the absolute session-store root this launcher shares.
|
||||
*/
|
||||
export function launcherSessionsRoot(): string {
|
||||
return resolveSessionsRoot()
|
||||
}
|
||||
|
||||
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
|
||||
the tui-agent 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.
|
||||
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
|
||||
* see {@link runTui}. Meta-mode sessions live under the checkout, so an id from
|
||||
* an ordinary `dsh` run in another directory is not found here.
|
||||
*/
|
||||
export async function runMeta(resumeSessionId: string | undefined): Promise<void> {
|
||||
return runTui(undefined, resumeSessionId, SOURCE_ROOT)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive TUI as a guided fresh session whose first turn invokes a
|
||||
* bundled skill (`dsh migrate` → `dsh-migrate`, `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<void> {
|
||||
return runTui(undefined, undefined, undefined, skill)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive TUI from the invoking directory.
|
||||
* @param config - a config path to boot instead of the shipped default, or
|
||||
* `undefined` for the default; already parsed from `--config`.
|
||||
* @param resumeSessionId - a persisted session id to resume, or `undefined`;
|
||||
* 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.
|
||||
* @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 MAIN_SESSION_ID_KEY}, so no config key selects the session.
|
||||
* @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}.
|
||||
*/
|
||||
export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise<void> {
|
||||
export async function runTui(
|
||||
config: string | undefined,
|
||||
resumeSessionId: string | undefined,
|
||||
workspace?: string,
|
||||
initialSkill?: 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.
|
||||
@@ -66,28 +125,52 @@ 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())
|
||||
// Both .env layers are loaded, so switching the workspace here cannot alter
|
||||
// environment 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 host
|
||||
// is offered only when Node exposes `process.execve` and knows its own entry.
|
||||
// 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 entry = process.argv[1]
|
||||
const execve = process.execve?.bind(process)
|
||||
const app: { current?: Context } = {}
|
||||
const resumeCommand = (sessionId: string): string =>
|
||||
`${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}`
|
||||
// Resuming reproduces THIS invocation with a different id. Meta mode is a
|
||||
// subcommand that rejects `--config`, while the default surface carries it, so
|
||||
// both the in-place handoff and the printed command derive from one shape.
|
||||
// `meta` is only reproducible for a target inside this checkout: it chdirs to
|
||||
// SOURCE_ROOT itself, which would override any other workspace, so a
|
||||
// cross-workspace resume takes the default surface and the caller supplies the
|
||||
// directory instead.
|
||||
const resumeArgs = (sessionId: string, targetCwd?: string): string[] =>
|
||||
workspace !== undefined && (targetCwd === undefined || targetCwd === workspace)
|
||||
? ['meta', `--resume=${sessionId}`]
|
||||
: [`--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : []]
|
||||
// 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`)
|
||||
// Rebuild argv from the parsed config plus the selected id: TUI mode's
|
||||
// only arguments are `--config <path>` and `--resume <id>`.
|
||||
const nextArgv = [
|
||||
process.execPath,
|
||||
...process.execArgv,
|
||||
entry,
|
||||
`--resume=${sessionId}`,
|
||||
...config !== undefined ? ['--config', config] : [],
|
||||
...resumeArgs(sessionId, cwd),
|
||||
]
|
||||
// `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) {
|
||||
@@ -108,16 +191,27 @@ export async function runTui(config: string | undefined, resumeSessionId: string
|
||||
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 (resumeSessionId !== undefined) {
|
||||
hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`)
|
||||
}
|
||||
// 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 and list-sessions see
|
||||
// every workspace. The bundle treats the slot as opaque.
|
||||
hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot())
|
||||
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)
|
||||
// Publication follows the store; meta mode already chdir'd, so each session
|
||||
// reports its own cwd.
|
||||
await registerLiveSessions(ctx)
|
||||
}
|
||||
/* v8 ignore stop */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { AppCLIEntry } from './app-cli-entry.ts'
|
||||
import { registerLiveSessions } from './register-session.ts'
|
||||
|
||||
const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url))
|
||||
|
||||
@@ -40,6 +41,7 @@ export async function runWeb(
|
||||
...trustedHosts !== undefined && { trustedHosts },
|
||||
})
|
||||
const { ctx, port: boundPort } = await entry.run()
|
||||
await registerLiveSessions(ctx)
|
||||
|
||||
let exiting = false
|
||||
const shutdown = (code: number): void => {
|
||||
|
||||
@@ -24,17 +24,33 @@ function exitCode(argv: string[]): number {
|
||||
afterEach(() => { vi.restoreAllMocks() })
|
||||
|
||||
describe('parseDshArgs', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => {
|
||||
it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => {
|
||||
expect(parse([])).toEqual({ mode: 'tui' })
|
||||
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
|
||||
expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' })
|
||||
expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' })
|
||||
// `meta` accepts `--resume` but does not redeclare it: a shared option parses
|
||||
// into program.opts() on either side of the subcommand, and redeclaring it
|
||||
// would leave the subcommand's own options empty and drop the id.
|
||||
expect(parse(['meta'])).toEqual({ mode: 'meta' })
|
||||
expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' })
|
||||
expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' })
|
||||
// Credential setup is option-free: it writes the Harness-home .env, so
|
||||
// there is nothing for a flag to select.
|
||||
// Bare `web` carries no host/port: the shipped cordis.yml owns the default.
|
||||
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
|
||||
// Host/port are unvalidated pass-throughs (the webserver schema gates them
|
||||
// at boot); the adapter only coerces the port string to a number.
|
||||
expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w']))
|
||||
.toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' })
|
||||
// Guided fresh-session entries carry nothing: bare mode discriminant only.
|
||||
expect(parse(['migrate'])).toEqual({ mode: 'migrate' })
|
||||
expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' })
|
||||
// `list-sessions` has one option and no workspace filter: the listing is always
|
||||
// global. `ps` is its alias and resolves to the same mode.
|
||||
expect(parse(['list-sessions'])).toEqual({ mode: 'list-sessions', json: false })
|
||||
expect(parse(['list-sessions', '--json'])).toEqual({ mode: 'list-sessions', json: true })
|
||||
expect(parse(['ps', '--json'])).toEqual({ mode: 'list-sessions', json: true })
|
||||
// --trusted-host is variadic and repeatable; authorities pass through unvalidated.
|
||||
expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9']))
|
||||
.toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] })
|
||||
@@ -55,6 +71,27 @@ describe('parseDshArgs', () => {
|
||||
expect(exitCode(['web', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['web', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1)
|
||||
// Same rule for credential setup: it shares no option with the default
|
||||
// surface, so a leaked flag is a typo, not something to ignore.
|
||||
// `meta` fixes its own config tree and is interactive, so --config/-p are
|
||||
// rejected; an empty id is swallowed downstream exactly as above.
|
||||
expect(exitCode(['meta', '--resume='])).toBe(1)
|
||||
expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['meta', '-p', 'task'])).toBe(1)
|
||||
// `migrate`/`upgrade` take no options: any leaked default-surface flag is a
|
||||
// mistyped invocation, not a silently-dropped input.
|
||||
expect(exitCode(['migrate', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['migrate', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['upgrade', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1)
|
||||
// `list-sessions`/`ps` is read-only and shares no default-surface option: a leaked flag is a
|
||||
// mistyped invocation, not a listing with a silently dropped input.
|
||||
expect(exitCode(['ps', '--resume', 's'])).toBe(1)
|
||||
expect(exitCode(['list-sessions', '--config', 'c.yml'])).toBe(1)
|
||||
expect(exitCode(['list-sessions', '-p', 'task'])).toBe(1)
|
||||
expect(exitCode(['--resume', 's', 'ps'])).toBe(1)
|
||||
})
|
||||
|
||||
it('exits 0 for --help (disclosing web) and --version', () => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under
|
||||
@@ -16,19 +17,28 @@ import { describe, expect, it } from 'vitest'
|
||||
* node_modules, so no external consumer is assembled; missing-config fail-loud
|
||||
* and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's
|
||||
* built-bin suite, and interactive TTY behavior is PTY-covered by
|
||||
* examples/tui-agent. Skips before the bin is built.
|
||||
* examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that
|
||||
* boots no agent tree, so the built bin is the whole product path.
|
||||
* Skips before the bin is built.
|
||||
*/
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshBin = join(repoRoot, 'apps/cli/lib/bin.js')
|
||||
|
||||
/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */
|
||||
async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin], {
|
||||
/**
|
||||
* Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output
|
||||
* + exit code. `env` isolates the Harness home for surfaces that read it.
|
||||
*/
|
||||
async function runBuiltBin(
|
||||
args: readonly string[] = [],
|
||||
env: Record<string, string> = {},
|
||||
): Promise<{ stdout: string; code: number; stderr: string }> {
|
||||
const result = await execa(process.execPath, [dshBin, ...args], {
|
||||
input: '',
|
||||
timeout: 25_000,
|
||||
killSignal: 'SIGKILL',
|
||||
reject: false,
|
||||
env,
|
||||
})
|
||||
if (result.timedOut) {
|
||||
throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`)
|
||||
@@ -45,4 +55,37 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)',
|
||||
// The refusal happens before any plugin mounts: stdout stays silent.
|
||||
expect(stdout).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
describe('dsh list-sessions', () => {
|
||||
let home: string
|
||||
beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) })
|
||||
afterEach(() => { rmSync(home, { recursive: true, force: true }) })
|
||||
|
||||
it('reports an empty listing as success, not an error', async () => {
|
||||
const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stdout.trim()).toBe('no dsh sessions running')
|
||||
expect(stderr).toBe('')
|
||||
}, 30_000)
|
||||
|
||||
it('emits an empty JSON array for machines', async () => {
|
||||
const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(JSON.parse(stdout)).toEqual([])
|
||||
}, 30_000)
|
||||
|
||||
it('runs without a TTY, unlike the TUI surface', async () => {
|
||||
// The listing is read-only and boots no agent tree, so piped stdio — the
|
||||
// launch the TUI refuses — is a supported way to run it.
|
||||
const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home })
|
||||
expect(code).toBe(0)
|
||||
expect(stderr).not.toContain('interactive TTYs')
|
||||
}, 30_000)
|
||||
|
||||
it('rejects a leaked default-surface flag instead of listing', async () => {
|
||||
const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home })
|
||||
expect(code).not.toBe(0)
|
||||
expect(stderr).toContain('list-sessions takes none of')
|
||||
}, 30_000)
|
||||
})
|
||||
})
|
||||
|
||||
74
apps/cli/tests/list-sessions.spec.ts
Normal file
74
apps/cli/tests/list-sessions.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Tests for the `dsh list-sessions` presentation layer: uptime formatting, row building
|
||||
* (newest first, absent-title placeholder) and table alignment without trailing
|
||||
* padding. Every displayed field comes from the record, so there is no log
|
||||
* reading to cover here.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry'
|
||||
import { buildRows, formatUptime, renderTable } from '../src/list-sessions.ts'
|
||||
|
||||
function record(overrides: Partial<SessionRegistryRecord> = {}): SessionRegistryRecord {
|
||||
return {
|
||||
sessionId: SessionId('sess-1'),
|
||||
pid: 4242,
|
||||
cwd: '/work/project',
|
||||
startedAt: 1_000,
|
||||
bootId: BootId('boot-1'),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('formatUptime', () => {
|
||||
it.each([
|
||||
[0, '0s'],
|
||||
[999, '0s'],
|
||||
[12_000, '12s'],
|
||||
[59_999, '59s'],
|
||||
[60_000, '1m'],
|
||||
[3_540_000, '59m'],
|
||||
[3_600_000, '1h'],
|
||||
[8_040_000, '2h14m'],
|
||||
[86_400_000, '1d'],
|
||||
[90_000_000, '1d1h'],
|
||||
])('renders %ims as %s', (ms, expected) => {
|
||||
expect(formatUptime(ms)).toBe(expected)
|
||||
})
|
||||
|
||||
it('never renders a negative duration for a clock that moved backwards', () => {
|
||||
expect(formatUptime(-5_000)).toBe('0s')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRows', () => {
|
||||
it('orders newest first and marks a missing title', () => {
|
||||
const rows = buildRows([
|
||||
record({ sessionId: SessionId('older'), startedAt: 1_000 }),
|
||||
record({ sessionId: SessionId('newer'), startedAt: 5_000 }),
|
||||
], 65_000)
|
||||
expect(rows.map(row => row[0])).toEqual(['newer', 'older'])
|
||||
expect(rows[0]).toEqual(['newer', '4242', '1m', '/work/project', '—'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderTable', () => {
|
||||
it('aligns columns and leaves no trailing whitespace', () => {
|
||||
const table = renderTable(buildRows([
|
||||
record({ sessionId: SessionId('short'), startedAt: 0, title: 'a title' }),
|
||||
record({ sessionId: SessionId('a-much-longer-session-id'), startedAt: 1, title: 'a title' }),
|
||||
], 1_000))
|
||||
const lines = table.split('\n')
|
||||
expect(lines[0]).toMatch(/^SESSION {18}\s+PID/)
|
||||
for (const line of lines) expect(line).toBe(line.trimEnd())
|
||||
// The header and every row align on the same column starts.
|
||||
const pidColumn = (line: string): number => line.includes('4242') ? line.indexOf('4242') : line.indexOf('PID')
|
||||
expect(pidColumn(lines[1] ?? '')).toBe(pidColumn(lines[0] ?? ''))
|
||||
expect(pidColumn(lines[2] ?? '')).toBe(pidColumn(lines[0] ?? ''))
|
||||
})
|
||||
|
||||
it('renders a header even with no rows, so the columns stay discoverable', () => {
|
||||
expect(renderTable([])).toBe('SESSION PID UPTIME WORKSPACE TITLE\n')
|
||||
})
|
||||
})
|
||||
20
apps/cli/tests/sessions-root.spec.ts
Normal file
20
apps/cli/tests/sessions-root.spec.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Pins the launcher side of the shared-session-store contract: `dsh` defaults
|
||||
* its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The
|
||||
* plugin side — the slot treated as opaque, explicit config winning, and a
|
||||
* project-local fallback with no globality assumption — is pinned by
|
||||
* `packages/examples/tui-demo/tests/tui-agent.spec.ts`.
|
||||
*/
|
||||
|
||||
import { join, resolve } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { launcherSessionsRoot } from '../src/tui.ts'
|
||||
|
||||
afterEach(() => vi.unstubAllEnvs())
|
||||
|
||||
describe('launcherSessionsRoot', () => {
|
||||
it('defaults the boot slot to sessions under DSH_HOME', () => {
|
||||
vi.stubEnv('DSH_HOME', '/tmp/dsh-slot-home')
|
||||
expect(launcherSessionsRoot()).toBe(resolve(join('/tmp/dsh-slot-home', 'sessions')))
|
||||
})
|
||||
})
|
||||
@@ -29,6 +29,15 @@
|
||||
{
|
||||
"path": "../../packages/util/paths"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/session-registry/session-registry"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/session-registry/session-registry-file"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/session-registry/session-registry-live"
|
||||
},
|
||||
{
|
||||
"path": "../../packages/client/connection"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user