Merge origin/master into workspace-write default

# Conflicts:
#	apps/cli/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-31 20:44:15 +08:00
45 changed files with 421 additions and 214 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md
2026-07-28-launcher-owned-resume-identity.md: e11431716305ff18e951ca45921c71dcd105974c
2026-07-28-launcher-owned-resume-identity.zh.md: e4078023cb1519288f0367f8973788f361aaa0bd
2026-07-28-launcher-owned-resume-identity.md: 167c9e848a9101c9d1e93cf3af968b00279db32b
2026-07-28-launcher-owned-resume-identity.zh.md: 218b69581e65e8566ff1047603bb71c9537d3486

View File

@@ -10,7 +10,7 @@ Two facts a launcher owns were shipped as deployment config keys on the TUI app
Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured".
Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume <valid-id>` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause.
Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume <valid-id>` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh experimental-meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause.
A config key cannot express these facts safely, because the deployment is not the authority on them.
@@ -25,7 +25,7 @@ Both sit beside the existing `tuiResumeHost` host capability, which set the prec
Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md).
`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume <id>` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout.
`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line names `--config` when one was passed. Resume always re-enters the default surface through `dsh --resume <id>`; `dsh experimental-meta` accepts no options and always starts fresh.
**`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly.

View File

@@ -10,7 +10,7 @@ Status: implemented
把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。
两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume <valid-id>` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。
两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume <valid-id>` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh experimental-meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。
一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。
@@ -25,7 +25,7 @@ Status: implemented
身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。
`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume <id>`——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。
`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行会在传入了 `--config` 时将其写入命令。恢复始终通过 `dsh --resume <id>` 重新进入默认界面;`dsh experimental-meta` 不接受任何选项,并且总是启动新会话。
**`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md
2026-07-28-dsh-guided-skill-session-commands.md: a11807dca9ad1640857cd95a8b528df691b8a27e
2026-07-28-dsh-guided-skill-session-commands.zh.md: e48420ed77496d8c336031473364f9008fccced7
2026-07-28-dsh-guided-skill-session-commands.md: 9d5341880e92d88781278238f21380919c962820
2026-07-28-dsh-guided-skill-session-commands.zh.md: 90c47d64173161fa4ef1f975de37b9cd42e02c99

View File

@@ -1,4 +1,4 @@
# Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill
# Agent Note: `dsh migrate`/`dsh experimental-upgrade` seed the first turn with a skill
Status: implemented
@@ -10,7 +10,7 @@ Two recurring flows begin with the user manually invoking one skill and answerin
## Decision
`dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:<name>` and pressed Enter.
`dsh migrate` and `dsh experimental-upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:<name>` and pressed Enter.
The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:<name>` runs, including the "Unknown skill" notice. The launcher passes the skill name to the TUI through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set.
@@ -26,7 +26,7 @@ The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED
`packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`.
No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery.
No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh experimental-upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery.
## Alternatives considered

View File

@@ -1,4 +1,4 @@
# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮
# Agent Note:`dsh migrate`/`dsh experimental-upgrade` 以 skill 播种首轮
Status: implemented
@@ -10,7 +10,7 @@ Status: implemented
## 决策
`dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:<name>` 并回车。
`dsh migrate` 与 `dsh experimental-upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:<name>` 并回车。
播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:<name>` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给 TUI,与 `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。
@@ -26,7 +26,7 @@ Status: implemented
`packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。
无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。
无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh experimental-upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。
## 考虑过的替代方案

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md
2026-07-28-dsh-meta-source-workspace.md: d65e0e6ff092b63931dd58c52fa76fb76a071dff
2026-07-28-dsh-meta-source-workspace.zh.md: 72a0c65e6eeda7d63dccc2306a3d7625cdb54362
2026-07-28-dsh-meta-source-workspace.md: be414ddbf63dd95791f9ca956b2f345b1fc8c685
2026-07-28-dsh-meta-source-workspace.zh.md: 86fc135db290766cb6fc2abefb194120416bfd9d

View File

@@ -1,4 +1,4 @@
# Agent Note: `dsh meta` boots the TUI over the harness checkout
# Agent Note: `dsh experimental-meta` boots the TUI over the harness checkout
Status: implemented
@@ -10,40 +10,32 @@ English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md)
## Decision
`dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory.
`dsh experimental-meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory.
The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts` — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active.
The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by a new optional third parameter that only `runMeta` passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent.
The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by an optional third parameter that only the `experimental-meta` dispatch passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent.
`meta` accepts only `--resume <id>`. `--config` would boot a foreign tree against the harness workspace, which is the `--config` case rather than this one; `-p` is not interactive. Both fail loud, as does an empty `--resume=` — matching the default surface, where a swallowed empty id would silently start a fresh session.
**`meta` does not redeclare `--resume`.** Commander parses an option a subcommand shares with its parent into `program.opts()` and leaves the subcommand's own options object empty, so redeclaring it silently dropped the id (found by probing the adapter, not by review). The action reads `program.opts()`, which also accepts the flag on either side of the subcommand; `--help` still lists it among the parent's options.
`experimental-meta` always starts a fresh session and accepts no options. `--config` would boot a foreign tree against the harness workspace, which is the default surface's `--config` case rather than this command; `-p` is not interactive, and resume re-enters the persisted session's own workspace through `dsh --resume <id>`. Any leaked default-surface option fails loud.
## Testing
`apps/cli/tests/args.spec.ts` extends its two existing cases rather than adding a file: routing for `meta`, `meta --resume <id>`, and `--resume <id> meta` (pinning the shared-option behavior above), and exit-1 for `meta --resume=`, `meta --config`, and `meta -p`. `runMeta` itself is composition inside the module's existing `v8 ignore` block, like `runTui`.
`apps/cli/tests/args.spec.ts` pins routing for `experimental-meta`, rejection of every leaked default-surface option, and rejection of the former `meta` name. The dispatch itself is composition inside `bin.ts`'s existing `v8 ignore` block.
There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir.
There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh experimental-meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir.
The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one.
`dsh meta --resume <valid-id>` once started a *fresh* session instead of resuming — a pre-existing defect on the default surface, not one this mode introduced. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) found the cause and fixed it: a personal overlay had replaced the whole `tui-agent` config block, overwriting the shipped `resumeSessionId` intake with a read of an unset environment variable, so a valid id was silently ignored. Session identity is now a launcher-owned context slot that no config key can displace, and `meta` routes through it.
## Alternatives considered
**Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it.
**A `--meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape.
**An `--experimental-meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `experimental-meta` alongside `web` matches the existing shape.
**Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another.
**Make the printed resume hint mode-aware.** Deferred here as a known cost, then delivered by [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md): the exit line became a launcher-provided context slot, so meta mode prints `dsh meta --resume <id>` and a copied hint works from any directory. It previously came from static config as `dsh --resume {session}` and only worked when re-run from the checkout.
## Consequences
Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. Meta sessions are isolated in the checkout's `.sessions/`, so `dsh meta --resume` sees only other meta sessions — intended, since a session's logged cwd belongs to its workspace.
The resume hint was this mode's original cost and is now resolved. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) made both the printed line and the in-place `/resume` handoff reproduce the mode as `dsh meta --resume <id>` from one shared argv helper, so a copied hint works from any directory and the handoff no longer depends implicitly on `execve` preserving the process cwd.
Starting a session on dsh's own source is `dsh experimental-meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. The command always starts fresh; an ordinary `dsh --resume <id>` later restores the session and enters its persisted workspace.
`runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it.

View File

@@ -1,4 +1,4 @@
# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI
# Agent Note:`dsh experimental-meta` 以 harness 检出为 workspace 启动 TUI
Status: implemented
@@ -10,40 +10,32 @@ Status: implemented
## Decision
`dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。
`dsh experimental-meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。
目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。
机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个新的可选第三参数把守,只有 `runMeta` 会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。
机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个可选第三参数把守,只有 `experimental-meta` 分派会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。
`meta` 只接受 `--resume <id>`。`--config` 会以 harness workspace 启动其他配置树,那属于 `--config` 的场景而非本场景;`-p` 并非交互式。两者都会明确报错,空的 `--resume=` 亦然——与默认界面一致,在那里被吞掉的空 id 会静默开启一个新会话。
**`meta` 不重新声明 `--resume`。** 对于子命令与父命令共享的选项,Commander 会将其解析进 `program.opts()`,而把子命令自身的 options 对象留空;因此重新声明会静默丢弃该 id(这是通过实测适配器发现的,而非评审发现)。action 读取 `program.opts()`,这同时也允许该标志出现在子命令的任意一侧;`--help` 仍会在父命令的选项中列出它。
`experimental-meta` 始终启动新会话,且不接受任何选项。`--config` 会针对 harness workspace 启动其他配置树,那是默认界面的 `--config` 场景,而不是该命令的场景;`-p` 并非交互式,恢复则通过 `dsh --resume <id>` 重新进入已持久化会话自身的 workspace。任何泄漏的默认界面选项都会明确报错。
## Testing
`apps/cli/tests/args.spec.ts` 扩展其已有的两个用例而非新增文件:`meta`、`meta --resume <id>` 与 `--resume <id> meta` 的路由(钉住上述共享选项行为),以及 `meta --resume=`、`meta --config`、`meta -p` 的退出码 1。`runMeta` 自身与 `runTui` 一样,属于该模块既有 `v8 ignore` 块内的组合代码。
`apps/cli/tests/args.spec.ts` 钉住 `experimental-meta` 的路由、对每个泄漏的默认界面选项的拒绝,以及对旧名称 `meta` 的拒绝。该分派本身是 `bin.ts` 既有 `v8 ignore` 块内的组合代码。
该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。
该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh experimental-meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。
取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录,git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。
`dsh meta --resume <有效 id>` 曾经开启一个*新*会话而非恢复——这是默认界面上既已存在的缺陷,并非本 mode 引入。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 查明了原因并将其修复:一个个人 overlay 替换了整个 `tui-agent` 配置块,用对一个未设置的环境变量的读取覆盖了已交付的 `resumeSessionId` 入口,因此有效的 id 会被静默忽略。会话标识如今是一个启动器拥有的上下文槽位,没有任何配置键能取代它,而 `meta` 经由它进行路由。
## Alternatives considered
**通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd(`!!js process.cwd()`、`persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。
**在默认界面上加一个 `--meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta` 与 `web` 并列符合既有形态。
**在默认界面上加一个 `--experimental-meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`experimental-meta` 与 `web` 并列符合既有形态。
**解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。
**让打印的恢复提示随 mode 变化。** 在此作为已知代价推迟,随后由 [由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 交付:退出行变成了一个启动器提供的上下文槽位,因此 meta 模式打印 `dsh meta --resume <id>`,被复制的提示在任意目录下都有效。它此前来自静态配置,固定为 `dsh --resume {session}`,且只有在检出目录中重新运行才有效。
## Consequences
在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。meta 会话被隔离在检出目录的 `.sessions/` 内,因此 `dsh meta --resume` 只能看到其他 meta 会话——这是预期行为,因为会话记录的 cwd 属于它的 workspace。
恢复提示曾是本 mode 的原初代价,如今已解决。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 让打印的行与原地 `/resume` 移交都从同一个共享的 argv 辅助函数将该 mode 复现为 `dsh meta --resume <id>`,因此被复制的提示在任意目录下都有效,且移交不再隐式依赖于 `execve` 保留进程 cwd。
在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh experimental-meta`,且该 workspace 必然就是告知模型的那个检出目录。该命令始终启动新会话;之后,普通的 `dsh --resume <id>` 会恢复该会话并进入其已持久化的 workspace。
`runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-skill-invocation-policy.md
2026-07-28-skill-invocation-policy.md: f74b0bcfddb1699c48279b4d8b153cabf764b140
2026-07-28-skill-invocation-policy.zh.md: 1a7117a382be224c5371964dd4ad3e916d4e0917
2026-07-28-skill-invocation-policy.md: e639db39c0e971ea6988ef6e9801ab71f8d1337f
2026-07-28-skill-invocation-policy.zh.md: 7e68f2cf28fbaa734dff477441fc8ab73e7367b7

View File

@@ -18,7 +18,7 @@ The local parser also exposed an internal camel-case spelling as frontmatter. Su
The local provider accepts the exact kebab-case frontmatter keys `disable-model-invocation` and `user-invocable`. It accepts YAML booleans plus case-insensitive `true`/`false`, `yes`/`no`, `on`/`off`, and `1`/`0`, matching the practical boolean forms accepted by Claude skills. It maps `disable-model-invocation` to the inverse positive field and fills both positive fields from their defaults even when neither key is present. A camel-case external spelling or non-boolean invocation value drops the entire skill from discovery with a targeted warning; this pre-release repository does not keep an on-disk compatibility alias. Invocation data fails closed because ignoring it would default to permission and could expose the skill on a disabled surface, while wrong-typed optional `whenToUse` and `metadata` values are omitted because they do not decide invocation.
The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
The model-facing `dsh-tool-skill` catalog and loader enforce `isModelInvocable`. The TUI `/skill:` autocomplete and exact loader enforce the user field locally, so a user-only skill is visible and loadable there even when it is absent from model discovery, without turning the optional skill peer into a runtime import. The launcher-seeded initial skill used by guided `dsh migrate` and `dsh experimental-upgrade` sessions follows this same TUI path and must remain user-invocable. The browser `skill.list` RPC serves a user-selected reference that still asks the model to load the skill, so it exposes the intersection of model- and user-invocable skills; no direct browser skill-loading RPC is added.
These rules permit all four combinations:

View File

@@ -18,7 +18,7 @@ skill 注册表最初将发现操作视为模型目录:`ctx.skills.list()` 会
本地提供方只接受拼写完全一致的 kebab-case frontmatter 键 `disable-model-invocation` 和 `user-invocable`。它接受 YAML 布尔值,以及不区分大小写的 `true`/`false`、`yes`/`no`、`on`/`off` 和 `1`/`0`,与 Claude skills 实际支持的布尔写法一致。它将 `disable-model-invocation` 映射为相反的正向字段,即使两个键都不存在,也会根据默认值填充两个正向字段。若使用外部驼峰式拼写或提供非布尔调用值,发现流程会丢弃整个 skill,并给出有针对性的警告;本仓库尚处于发布前阶段,因此不为磁盘格式保留兼容别名。调用数据校验遵循失败时默认拒绝原则,因为忽略这类数据会默认授予权限,可能使 skill 暴露在已禁用的接口上;与之不同,类型错误的可选 `whenToUse` 和 `metadata` 值会被省略,因为它们不参与调用判定。
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。
面向模型的 `dsh-tool-skill` 目录和 loader 执行 `isModelInvocable`。TUI 的 `/skill:` 自动补全与精确名称 loader 在本地执行用户字段,因此仅允许用户调用的 skill 即使不出现在模型发现结果中,仍会在此处显示并可加载,同时不会将可选的 skill peer 变成运行时导入。由 launcher 预置、供引导式 `dsh migrate` 和 `dsh experimental-upgrade` 会话使用的初始 skill 沿用同一条 TUI 路径,因此必须保持允许用户调用。浏览器的 `skill.list` RPC 提供的是由用户选择、但仍要求模型加载的引用,因此只公开同时允许模型和用户调用的 skill;本次改动不新增让浏览器直接加载 skill 的 RPC。
这些规则允许以下四种组合:

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write README.md
README.md: b447c9634189353854e8be9d0bf597a8b0c7e371
README.zh.md: f8bbbc36bc670403c0b9a40977f32f598e77ee46
README.md: fb956dce51838438fb508db7ea9ebdf9e0b3a50b
README.zh.md: aa80b744465d7d253a54fffeead7262a1fdf69eb

View File

@@ -16,10 +16,12 @@ It uses an architecture where **everything is a plugin**.
## Install
Install `dsh` with one command:
Clone the repository, then run the installer:
```sh
curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
git clone <repo-url>
cd deepseek-harness
scripts/install.sh
```
The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, and prompts for a DeepSeek API key.

View File

@@ -16,10 +16,12 @@ DeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源
## 安装
使用一条命令安装 `dsh`:
克隆仓库,然后运行安装器:
```sh
curl -fsSL https://raw.githubusercontent.com/deepseek-harness/deepseek-harness/master/scripts/install.sh | sh
git clone <repo-url>
cd deepseek-harness
scripts/install.sh
```
安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥。

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/README.md
README.md: c43658e066b42c58252b1665c2cd3883d5cb5d4b
README.zh.md: 0690231e637135f6f2481880bee520958ad5b5c0
README.md: fd44108b7d01b63d8391793285f301d2cd25f7d9
README.zh.md: 209f6b9f91081e28cd8543fbac3c79f67dd5acb3

View File

@@ -3,21 +3,21 @@
English | [中文](README.zh.md)
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`, `--dump-config`, `--dump-default-config`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, 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 — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag 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 Web overlay 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`, `--dump-config`, `--dump-default-config`), whose `experimental-meta` subcommand is the same TUI over this checkout, whose `experimental-upgrade` subcommand is an option-less guided-session entry, 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 — `experimental-upgrade`, `web`, `experimental-meta` — rejects a leaked `--config`/`-p`/`--resume`/dump flag 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 Web overlay 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 `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config <path>` applies a patch-list overlay instead of the personal overlay, while `--config-replace <path>` boots that file as the complete tree;
- 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);
- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh experimental-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)): `config.yaml` patches the booted tree, while `.env` there is the credential provider's own store (never hoisted into the environment, so keys stay rotatable). Environment precedence is ambient > project `.env`.
- presents the [versioned first-run welcome](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md) through the mounted TUI overlay service when its immutable marker is absent under `DSH_HOME`; only Enter creates that version's marker, while Escape, disposal, or process exit leaves it eligible. The official DeepSeek icon, responsive terminal rasters, all-locale Chinese copy, and notice version are static local owners; the overlay never writes a session event or model context.
- registers bare `/compact`: while the agent is idle, it summarizes useful older history even below automatic pressure, rejects arguments, and reports success only after the standalone replacement bracket is durable. A prompt submitted during compaction keeps its queue identity and starts after that checkpoint; injected context remains visible.
`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 the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
`dsh experimental-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 the environment is settled, so precedence is unchanged while the session cwd and HMR watch root move together. Experimental meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume <id>` to resume a persisted session.
`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, 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 experimental-upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, 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. The command takes no options — `--config`, `-p`, and `--resume` fail loud — and seeds only on this first launch, so a later `dsh --resume <id>` of the session is an ordinary TUI session with no re-injection.
`dsh --dump-config` and `dsh web --dump-config` print the composed config tree — the shipped base, the surface overlay, and the `--config` or personal overlay, exactly the layers that surface would boot — as YAML on stdout and exit without booting; `--dump-default-config` stops at the surface overlay, so diffing the two shows precisely what the user layer changes. Each run of rows is preceded by a `# ==` comment naming the file it comes from and the layers that patched it (e.g. `# == base.cordis.yml, patched by tui.cordis.yml`), so the output shows provenance while staying one loadable document. Composition runs through the include's own patch algorithm and YAML dialect (`applyEntryPatches`/`entryListSchema` from `@cordisjs/plugin-include`), so the dump cannot drift from what boots; `!!js` expressions print verbatim and unevaluated, and a patch whose target row is absent is reported on stderr with its layer, mirroring the Loader's boot-time warning. Launcher-owned boot-context values (session identity, CLI-flag patches) are per-invocation facts outside the config tree and do not appear. The dump flags reject boot-only flags (`-p`, `--resume`, `--config-replace`) rather than silently ignoring them, and `--dump-default-config` takes no `--config`.

View File

@@ -3,21 +3,21 @@
[English](README.md) | 中文
Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`、`--dump-config`、`--dump-default-config`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--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`、`--dump-config`、`--dump-default-config`),`experimental-meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`experimental-upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`experimental-upgrade`、`web`、`experimental-meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`/dump 标志,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。
TUI 界面:
- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config <path>` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace <path>` 将指定文件作为完整配置树启动;
- 使用 `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` 是唯一例外,见下文);
- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh experimental-meta` 是唯一例外,见下文);
- 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它;
- 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`config.yaml` 修补已启动的树,而那里的 `.env` 是凭据 provider 自己的存储(绝不会被提升进环境,因此密钥始终可轮换)。环境优先级为环境中已有的值 > 项目 `.env`。
- 当 `DSH_HOME` 下不存在不可变确认标记时,通过已挂载的 TUI overlay 服务呈现[版本化首次运行欢迎页](../../.agents/notes/implemented/feature/2026-07-30-versioned-tui-first-run-welcome.md);只有 Enter 会创建该版本的标记,Escape、资源释放或进程退出仍保留展示资格。官方 DeepSeek 图标、响应式终端栅格图、所有 locale 共用的中文文案和通知版本均由静态本地文件持有;overlay 不会写入会话事件或模型上下文。
- 注册裸 `/compact`:agent 空闲时,即使未达到自动压力,也会摘要有效的较早历史;该命令拒绝参数,并只在独立替换标记对持久化后报告成功。压缩(compaction)期间提交的提示词保留其队列身份,并在该检查点之后启动;注入的上下文仍保持可见。
`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`。
`dsh experimental-meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在环境确定之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Experimental meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume <id>`。
`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
`dsh experimental-upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:<name>`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。该命令不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume <id>` 恢复该会话时是普通 TUI 会话,不会重复注入。
`dsh --dump-config` 和 `dsh web --dump-config` 把合成后的配置树——已交付的基础配置、界面覆盖层,以及 `--config` 或个人覆盖层,恰好是该界面启动时组装的那些层——以 YAML 打印到 stdout 后退出,不启动任何东西;`--dump-default-config` 止步于界面覆盖层,因此对两份输出做 diff 就能精确看出用户层改了什么。每段连续的行之前都有一条 `# ==` 注释,标明该段来自哪个文件以及被哪些层修补过(例如 `# == base.cordis.yml, patched by tui.cordis.yml`),因此输出既展示来源,又仍是一份可加载的文档。合成通过 include 自己的补丁算法和 YAML 方言(`@cordisjs/plugin-include` 的 `applyEntryPatches`/`entryListSchema`)完成,因此 dump 不可能与实际启动漂移;`!!js` 表达式原样打印、不求值,目标行不存在的补丁会连同其所在层报到 stderr,与 Loader 启动时的警告一致。由启动器持有的启动上下文值(会话身份、CLI 标志补丁)是每次调用的事实,位于配置树之外,不会出现。dump 标志会拒绝仅用于启动的标志(`-p`、`--resume`、`--config-replace`)而不是静默忽略它们,`--dump-default-config` 不接受 `--config`。

View File

@@ -2,8 +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; `meta` and
* `web` are real subcommands. Commander owns `--help`/`--version` and parse
* subcommand) is the TUI/headless surface with option-only flags;
* `experimental-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
@@ -51,8 +52,8 @@ interface MetaInvocation {
}
/**
* Guided fresh-session entry: `dsh upgrade` seeds the first turn with the
* `dsh-upgrade` skill. It always mints a
* Guided fresh-session entry: `dsh experimental-upgrade` seeds the first turn
* with the `dsh-upgrade` skill. It 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.
*/
@@ -248,14 +249,15 @@ Examples:
}
// Registration order is the rendered help order, so daily use comes first
// and the harness-development surfaces (`web --dev`, `meta`) come last.
// `upgrade` is a guided fresh-session entry: it takes no options and always
// mints a fresh session, so nothing is left to carry.
// and the harness-development surfaces (`web --dev`, `experimental-meta`)
// come last. `experimental-upgrade` is a guided fresh-session entry: it
// takes no options and always mints a fresh session, so nothing is left to
// carry.
program
.command('upgrade')
.command('experimental-upgrade')
.description('update this dsh installation to the latest version')
.action(() => {
rejectParentOptions('upgrade')
rejectParentOptions('experimental-upgrade')
resolved = { mode: 'upgrade' }
})
@@ -283,10 +285,10 @@ Examples:
})
program
.command('meta')
.command('experimental-meta')
.description('work on the dsh source that runs this command, from any directory')
.action(() => {
rejectParentOptions('meta')
rejectParentOptions('experimental-meta')
resolved = { mode: 'meta' }
})

View File

@@ -8,10 +8,11 @@
* from it, so `dsh` acts on whatever project it is launched in. Session storage
* is the exception — it lives under the Harness home so `/resume` reaches every
* workspace, and an in-place resume enters the selected session's own directory.
* `dsh meta` is the one exception — it makes this harness checkout the
* workspace. `dsh upgrade` is a fresh session whose first turn auto-invokes a
* bundled skill. After boot, the agent's system prompt is told the path to this
* harness checkout so it can find its own source.
* `dsh experimental-meta` is the one exception — it makes this harness
* checkout the workspace. `dsh experimental-upgrade` is a fresh session whose
* first turn auto-invokes a bundled skill. After boot, the agent's system
* prompt is told the path to this harness checkout so it can find its own
* source.
* @module @deepseek-ai/dsh/tui
*/
@@ -70,7 +71,7 @@ const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.d
// The harness checkout root: three hops up from apps/cli/{src,lib}, resolved
// from this bin's location so it holds however `dsh` is launched (a PATH
// symlink, an arbitrary cwd). The agent is told where its own source lives.
/** The harness checkout used as the `dsh meta` workspace and source prompt path. */
/** The harness checkout used as the `dsh experimental-meta` workspace and source prompt path. */
export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
/* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers;
@@ -87,10 +88,11 @@ export const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url))
* {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session
* and an overlay replacing the agent row cannot drop it.
* @param workspace - a directory to make the workspace instead of the invoking
* one, or `undefined` to keep the cwd. Only `dsh meta` passes it.
* one, or `undefined` to keep the cwd. Only `dsh experimental-meta` passes it.
* @param initialSkill - a bundled skill to auto-invoke as a fresh session's
* first turn, or `undefined`. Set only by `dsh upgrade` and ignored on a resume,
* so it never re-fires; reaches the app through {@link INITIAL_SKILL_KEY}.
* first turn, or `undefined`. Set only by `dsh experimental-upgrade` and
* ignored on a resume, so it never re-fires; reaches the app through
* {@link INITIAL_SKILL_KEY}.
* @param configReplace - a config path to boot as the ENTIRE tree, bypassing the
* shared base, the TUI overlay, and the personal overlay alike, or `undefined`
* to compose them; already parsed from `--config-replace`.
@@ -138,8 +140,9 @@ export async function runTui(
const entry = process.argv[1]
const execve = process.execve?.bind(process)
const app: { current?: Context } = {}
// Resume always enters the default surface because meta rejects parent
// options, including `--resume`. The resumed session already persists its cwd.
// Resume always enters the default surface because experimental-meta rejects
// parent options, including `--resume`. The resumed session already persists
// its cwd.
const resumeArgs = (sessionId: string): string[] => [
`--resume=${sessionId}`,
// Both config flags must survive the handoff: resuming into a different

View File

@@ -24,13 +24,13 @@ function exitCode(argv: string[]): number {
afterEach(() => { vi.restoreAllMocks() })
describe('parseDshArgs', () => {
it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => {
it('routes each mode by its shape: default TUI, -p headless, experimental and web subcommands', () => {
expect(parse([])).toEqual({ mode: 'tui' })
expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' })
expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.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' })
expect(parse(['meta'])).toEqual({ mode: 'meta' })
expect(parse(['experimental-meta'])).toEqual({ mode: 'meta' })
// Bare `web` carries no host/port: the shipped Web overlay owns the default.
expect(parse(['web'])).toEqual({ mode: 'web', dev: false })
expect(parse(['web', '--config', 'web.yml'])).toEqual({ mode: 'web', dev: false, config: 'web.yml' })
@@ -39,7 +39,7 @@ describe('parseDshArgs', () => {
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(['upgrade'])).toEqual({ mode: 'upgrade' })
expect(parse(['experimental-upgrade'])).toEqual({ mode: 'upgrade' })
// --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'] })
@@ -64,8 +64,8 @@ describe('parseDshArgs', () => {
expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1)
expect(exitCode(['web', '--dump-default-config', '--config', 'w.yml'])).toBe(1)
// A leaked dump flag on a subcommand that has none is a mistyped invocation.
expect(exitCode(['meta', '--dump-config'])).toBe(1)
expect(exitCode(['upgrade', '--dump-config'])).toBe(1)
expect(exitCode(['experimental-meta', '--dump-config'])).toBe(1)
expect(exitCode(['experimental-upgrade', '--dump-config'])).toBe(1)
})
it('exits nonzero instead of silently starting fresh or dropping inputs', () => {
@@ -88,17 +88,20 @@ describe('parseDshArgs', () => {
expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1)
// Same rule for each subcommand that 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 always starts fresh, so every
// default-surface option is rejected.
expect(exitCode(['meta', '--resume', 's'])).toBe(1)
expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['meta', '-p', 'task'])).toBe(1)
// `upgrade` takes no options: any leaked default-surface flag is a
// mistyped invocation, not a silently-dropped input.
expect(exitCode(['upgrade', '--resume', 's'])).toBe(1)
expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1)
// `experimental-meta` fixes its own config tree and always starts fresh,
// so every default-surface option is rejected.
expect(exitCode(['experimental-meta', '--resume', 's'])).toBe(1)
expect(exitCode(['experimental-meta', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['experimental-meta', '--config-replace', 'tree.yml'])).toBe(1)
expect(exitCode(['experimental-meta', '-p', 'task'])).toBe(1)
// `experimental-upgrade` takes no options: any leaked default-surface flag
// is a mistyped invocation, not a silently-dropped input.
expect(exitCode(['experimental-upgrade', '--resume', 's'])).toBe(1)
expect(exitCode(['experimental-upgrade', '--config', 'c.yml'])).toBe(1)
expect(exitCode(['-p', 'task', 'experimental-upgrade'])).toBe(1)
// The pre-release command names have no compatibility aliases.
expect(exitCode(['meta'])).toBe(1)
expect(exitCode(['upgrade'])).toBe(1)
})
it('exits 0 for --help (disclosing web) and --version', () => {

View File

@@ -147,14 +147,21 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
await expect.poll(() => page.locator('tr[data-turn-start="true"]').count(), { timeout: 15_000 }).toBe(2)
await expect.poll(() => page.getByRole('columnheader').count(), { timeout: 10_000 }).toBe(0)
await page.locator('tr[data-kind="tool"]').first().click()
await expect.poll(() => page.getByRole('complementary', { name: 'Event details' }).count(), { timeout: 10_000 }).toBe(1)
const details = page.getByRole('complementary', { name: 'Event details' })
await expect.poll(() => details.count(), { timeout: 10_000 }).toBe(1)
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const darkSummarySurfaces = await details.getByRole('heading', { name: 'Payload' }).evaluate(heading => ({
heading: getComputedStyle(heading).backgroundColor,
panel: getComputedStyle(heading.closest('[aria-label="Event details"]')!).backgroundColor,
}))
expect(darkSummarySurfaces.heading).toBe(darkSummarySurfaces.panel)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await page.getByRole('tab', { name: 'Result' }).click()
await expect.poll(() => page.getByText('NAVIGATION_OK', { exact: false }).count(), { timeout: 10_000 }).toBeGreaterThanOrEqual(1)
const snapshot = (await captureStableAria(page, '[class*="viewArea"]', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
await compareOrRefreshGolden(TRAJECTORY_EXPECTED, snapshot, MODE)
await page.getByRole('complementary', { name: 'Event details' })
.getByRole('button', { name: 'Close details' }).click()
await details.getByRole('button', { name: 'Close details' }).click()
}, 60_000)
it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => {

View File

@@ -22,6 +22,7 @@ export default defineConfig({
{ find: /^@deepseek-ai\/dsh-client-web-react$/, replacement: src('../../packages/client/web-react/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-slots$/, replacement: src('../../packages/client/ui-slots/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-ui-primitives$/, replacement: src('../../packages/client/ui-primitives/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-schema-form$/, replacement: src('../../packages/client/schema-form/src/index.ts') },
{ find: /^@deepseek-ai\/dsh-client-modules\/client$/, replacement: src('../../packages/client/modules/src/client/index.ts') },
],
},

View File

@@ -2010,8 +2010,8 @@ export interface Config extends TuiConfig {
/**
* Skill name auto-invoked as this session's first user turn, exactly as if
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first
* turn to the user.
* skill-guided session (`dsh migrate`/`dsh experimental-upgrade`); absent
* leaves the first turn to the user.
*/
initialSkill?: string
}

View File

@@ -316,6 +316,7 @@ flowchart TD
pkg_client_ui_settings --> pkg_client_ui_primitives
pkg_client_ui_settings --> pkg_client_ui_slots
pkg_client_ui_settings --> pkg_invariants
pkg_client_ui_trajectory --> pkg_client_runtime
pkg_client_ui_trajectory --> pkg_client_ui_primitives
pkg_client_ui_trajectory --> pkg_invariants
pkg_credentials --> pkg_brand
@@ -1082,7 +1083,7 @@ flowchart TD
| [`client-locale`](../packages/client/locale) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) |
| [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) |
| [`credentials`](../packages/credentials/credentials) | `credentials` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) |
| [`helper`](../packages/sdk/helper) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess) |
| [`telemetry`](../packages/sdk/telemetry) | `sdk` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) |

View File

@@ -24,6 +24,7 @@
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
@@ -37,6 +38,7 @@
"diff": "^9.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"cordis": "^4.0.0-rc.7",

View File

@@ -321,6 +321,13 @@
width: 76px;
}
.kindSlot :global([role='tooltip']) {
border: 1px solid var(--dsw-alias-border-l2);
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv2);
color: var(--dsw-alias-label-primary);
}
.content {
padding-left: 4px !important;
color: var(--dsw-alias-label-primary);
@@ -828,6 +835,7 @@
flex: 1;
min-height: 0;
overflow: auto;
scrollbar-gutter: stable;
}
.detailBodySummary {
@@ -989,7 +997,7 @@
margin: 0;
padding: 0 0 3px 14px;
color: var(--dsw-alias-label-secondary);
background: var(--dsw-alias-bg-base);
background: var(--dsw-alias-bg-layer-1);
font: var(--dsw-font-xs-strong-13);
user-select: none;
}

View File

@@ -1906,7 +1906,10 @@ export function TrajectoryTable({
<span
className={css.kindSlot}
>
<Tooltip label={KIND_LABEL[record.cell.kind]} side="bottom">
<Tooltip
label={KIND_LABEL[record.cell.kind]}
side="right"
>
<span
className={`${css.kindTag} ${
record.cell.kind === 'system'

View File

@@ -1,6 +1,6 @@
.root {
position: relative;
z-index: 0;
z-index: 1;
isolation: isolate;
flex: none;
border-bottom: 1px solid var(--dsw-alias-border-l2);

View File

@@ -2,7 +2,7 @@
import {
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
type PointerEvent, type WheelEvent,
type PointerEvent,
} from 'react'
import type { TrajectoryTurnModel } from './layout.ts'
import {
@@ -70,11 +70,17 @@ function rangeFraction(
range: TrajectoryTimeRange,
start: number,
duration: number,
minimum: number,
maximum: number,
): FractionRange {
return orderedRange(
clampFraction((range.start - start) / duration),
clampFraction((range.end - start) / duration),
const bounded = orderedRange(
Math.min(maximum, Math.max(minimum, range.start)),
Math.min(maximum, Math.max(minimum, range.end)),
)
return {
start: (bounded.start - start) / duration,
end: (bounded.end - start) / duration,
}
}
function LaneLabels() {
@@ -117,6 +123,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
anchorClientX: number
recordIndex: number | null
} | null>(null)
const rootRef = useRef<HTMLElement | null>(null)
const trackRef = useRef<HTMLDivElement | null>(null)
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
const [hover, setHover] = useState<HoverPoint | null>(null)
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
@@ -183,16 +191,48 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
} as CSSProperties
const committed = model === null || range === null
? null
: rangeFraction(range, domainStart, domainDuration)
: rangeFraction(range, domainStart, domainDuration, model.start, model.end)
const draftFraction = model === null || draft === null
? null
: rangeFraction(draft, domainStart, domainDuration)
: rangeFraction(draft, domainStart, domainDuration, model.start, model.end)
const visibleRange = draftFraction ?? committed
const activeRange = draft ?? range
useEffect(() => {
const root = rootRef.current
if (root === null) return
const onWheel = (event: globalThis.WheelEvent): void => {
event.preventDefault()
const track = trackRef.current
if (track === null || model === null) return
setAnimateViewport(false)
const rect = track.getBoundingClientRect()
const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
const nextDuration = Math.min(
fullDuration,
Math.max(
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
domainDuration * Math.exp(event.deltaY * 0.0015),
),
)
if (nextDuration >= fullDuration * 0.999) {
setViewport(null)
return
}
const anchorTime = domainStart + anchorFraction * domainDuration
const nextStart = Math.min(
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
model.end - nextDuration,
)
setViewport({ start: nextStart, end: nextStart + nextDuration })
}
root.addEventListener('wheel', onWheel, { passive: false })
return () => { root.removeEventListener('wheel', onWheel) }
}, [domainDuration, domainStart, fullDuration, mode, model])
if (model === null) {
return (
<section className={css.root} aria-label="Trajectory timeline">
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
<div className={css.plot}>
<LaneLabels />
<div className={css.track}>
@@ -339,36 +379,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
setHover(null)
}
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
event.preventDefault()
setAnimateViewport(false)
const rect = event.currentTarget.getBoundingClientRect()
const anchorFraction =
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
const nextDuration = Math.min(
fullDuration,
Math.max(
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
domainDuration * Math.exp(event.deltaY * 0.0015),
),
)
if (nextDuration >= fullDuration * 0.999) {
setViewport(null)
return
}
const anchorTime = domainStart + anchorFraction * domainDuration
const nextStart = Math.min(
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
model.end - nextDuration,
)
setViewport({ start: nextStart, end: nextStart + nextDuration })
}
return (
<section className={css.root} aria-label="Trajectory timeline">
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
<div className={css.plot}>
<LaneLabels />
<div
ref={trackRef}
className={css.track}
aria-label="Timeline overview; drag horizontally to focus events"
tabIndex={0}
@@ -384,7 +400,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
event.preventDefault()
onRangeChange(null)
}}
onWheel={onWheel}
onContextMenu={(event) => {
event.preventDefault()
setAnimateViewport(false)

View File

@@ -149,7 +149,7 @@
font: var(--dsw-font-xxs-12);
}
.action:hover:not(:disabled) {
.action:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -159,11 +159,6 @@
outline-offset: 1px;
}
.action:disabled {
color: var(--dsw-alias-label-dimmed);
cursor: not-allowed;
}
.actionIcon {
color: var(--dsw-alias-label-tertiary);
font: 14px/14px var(--ds-font-family-code);

View File

@@ -8,18 +8,14 @@ export interface TrajectoryToolbarProps {
actualDuration: boolean
/** Select recorded-duration or equal-width blocks. */
onActualDurationChange: (actualDuration: boolean) => void
/** Whether recorded timing retains idle gaps between user turns. */
/** Whether recorded timing retains idle gaps between operations. */
actualTime: boolean
/** Select complete wall-clock timing or idle-compressed timing. */
onActualTimeChange: (actualTime: boolean) => void
/** Number of turns containing more than one row. */
collapsibleTurns: number
/** Whether every collapsible turn is currently folded. */
allTurnsCollapsed: boolean
/** Fold or expand every collapsible turn. */
onToggleAllTurns: () => void
/** Number of assistant messages followed by tool calls. */
collapsibleAssistants: number
/** Whether every collapsible assistant's tool calls are currently folded. */
allAssistantsCollapsed: boolean
/** Fold or expand tool calls under every collapsible assistant. */
@@ -40,10 +36,8 @@ export function TrajectoryToolbar({
onActualDurationChange,
actualTime,
onActualTimeChange,
collapsibleTurns,
allTurnsCollapsed,
onToggleAllTurns,
collapsibleAssistants,
allAssistantsCollapsed,
onToggleAllAssistants,
searchQuery,
@@ -91,7 +85,6 @@ export function TrajectoryToolbar({
aria-label={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
aria-pressed={allTurnsCollapsed}
title={allTurnsCollapsed ? 'Expand turns' : 'Collapse turns'}
disabled={collapsibleTurns === 0}
onClick={onToggleAllTurns}
>
<span className={css.actionIcon} aria-hidden="true">
@@ -105,7 +98,6 @@ export function TrajectoryToolbar({
aria-label={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
aria-pressed={allAssistantsCollapsed}
title={allAssistantsCollapsed ? 'Expand calls' : 'Collapse calls'}
disabled={collapsibleAssistants === 0}
onClick={onToggleAllAssistants}
>
<span className={css.actionIcon} aria-hidden="true">

View File

@@ -5,7 +5,7 @@ import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/clie
import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots'
import type {
AssistantMessageNode, ConversationContext,
SessionHistoryFace,
SessionHistoryFace, SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
import {
deriveTrajectoryContextBranches, trajectoryBranchContainsRequest,
@@ -29,8 +29,12 @@ const EMPTY_IDS: ReadonlySet<number> = new Set()
/** Session-history paging needed by the event-complete trajectory view. */
export interface TrajectoryViewInjected {
hooks: { history: SessionHistoryFace }
hooks: {
history: SessionHistoryFace
duration: SnapshotStore<boolean>
}
loadAllHistory: (signal: AbortSignal) => Promise<void>
setActualDuration: (actualDuration: boolean) => void
}
interface UsageLike {
@@ -134,7 +138,7 @@ function searchMatches(
}
export function TrajectoryView({
useHistory, loadAllHistory, inspect, onInspectDone,
useHistory, useDuration, loadAllHistory, setActualDuration, inspect, onInspectDone,
}: ConvViewProps & InjectFace<TrajectoryViewInjected>) {
const [collapsedTurns, setCollapsedTurns] = useState<ReadonlySet<number>>(EMPTY_IDS)
const [collapsedAssistants, setCollapsedAssistants] =
@@ -143,7 +147,7 @@ export function TrajectoryView({
branchId: number
range: TrajectoryTimeRange
} | null>(null)
const [actualDuration, setActualDuration] = useState(false)
const actualDuration = useDuration(value => value)
const [actualTime, setActualTime] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [selectedTimelineIndex, setSelectedTimelineIndex] = useState<number | null>(null)
@@ -467,10 +471,8 @@ export function TrajectoryView({
setActualTime(nextActualTime)
setTimelineSelection(null)
}}
collapsibleTurns={collapsibleTurnIds.length}
allTurnsCollapsed={allTurnsCollapsed}
onToggleAllTurns={toggleAllTurns}
collapsibleAssistants={collapsibleAssistantIds.length}
allAssistantsCollapsed={allAssistantsCollapsed}
onToggleAllAssistants={toggleAllAssistants}
searchQuery={searchQuery}

View File

@@ -0,0 +1,13 @@
import {
createSnapshotStore, type SnapshotStore,
} from '@deepseek-ai/dsh-client-runtime/client'
/**
* Create the browser-wide trajectory duration preference source.
* @returns a persisted source shared by every session view in one plugin lifecycle.
*/
export function createTrajectoryDurationStore(): SnapshotStore<boolean> {
return createSnapshotStore(false, {
persist: { name: 'dsh.trajectory.duration' },
})
}

View File

@@ -7,6 +7,7 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: the 'conversation.view' SlotMap row (declared by the slot's
// owning package) must be in the program for the register calls to type.
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
import { createTrajectoryDurationStore } from './duration-store.ts'
import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx'
/**
@@ -24,6 +25,7 @@ export const inject = ['slots', 'conversation', 'sessionHistory']
* @param ctx - client root context.
*/
export function apply(ctx: Context): void {
const duration = createTrajectoryDurationStore()
ctx.slots.register({
name: 'conversation.view',
id: 'trajectory',
@@ -32,8 +34,9 @@ export function apply(ctx: Context): void {
inject: (sessionId: SessionId): TrajectoryViewInjected => {
const history = ctx.sessionHistory.source(sessionId)
return {
hooks: { history },
hooks: { history, duration },
loadAllHistory: signal => history.loadAll(signal),
setActualDuration: (value) => { duration.set(value) },
}
},
}, TrajectoryView)

View File

@@ -116,14 +116,9 @@ export function deriveTrajectoryTimeline(
function deriveTimedTimeline(
turns: readonly TrajectoryTurnModel[],
actualDuration: boolean,
removeUserIdle: boolean,
compressIdle: boolean,
): TrajectoryTimelineModel | null {
const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
let removedUserIdle = 0
let previousTurnEnd: number | null = null
for (const turn of turns) {
const timedTurns = turns.flatMap((turn) => {
const rawSpans = turn.groups.flatMap(group =>
group.cells.flatMap((cell): TrajectoryTimelineSpan[] => {
if (cell.requestOnly === true) return []
@@ -140,30 +135,43 @@ function deriveTimedTimeline(
}]
}),
)
if (rawSpans.length === 0) continue
return rawSpans.length === 0 ? [] : [{ turn: turn.turn, rawSpans }]
})
const rawSpans = timedTurns.flatMap(turn => turn.rawSpans)
if (rawSpans.length === 0) return null
const turnStart = Math.min(...rawSpans.map(span => span.start))
const turnEnd = Math.max(...rawSpans.map(span => span.end))
if (removeUserIdle && previousTurnEnd !== null) {
removedUserIdle += Math.max(0, turnStart - previousTurnEnd)
const removedIdleBySpan = new Map<TrajectoryTimelineSpan, number>()
let removedIdle = 0
let coveredUntil: number | null = null
for (const span of [...rawSpans].sort((left, right) =>
left.start - right.start || left.end - right.end)) {
if (compressIdle && coveredUntil !== null && span.start > coveredUntil) {
removedIdle += span.start - coveredUntil
}
spans.push(...rawSpans.map(span => ({
...span,
start: span.start - removedUserIdle,
end: (actualDuration ? span.end : span.start) - removedUserIdle,
})))
removedIdleBySpan.set(span, removedIdle)
coveredUntil = coveredUntil === null ? span.end : Math.max(coveredUntil, span.end)
}
const spans: TrajectoryTimelineSpan[] = []
const turnBoundaries: TrajectoryTimelineTurnBoundary[] = []
for (const turn of timedTurns) {
const projected = turn.rawSpans.map((span): TrajectoryTimelineSpan => {
const offset = removedIdleBySpan.get(span) ?? 0
return {
...span,
start: span.start - offset,
end: (actualDuration ? span.end : span.start) - offset,
}
})
spans.push(...projected)
if (turn.turn !== null) {
turnBoundaries.push({
turn: turn.turn,
time: turnStart - removedUserIdle,
time: Math.min(...projected.map(span => span.start)),
})
}
previousTurnEnd = previousTurnEnd === null
? turnEnd
: Math.max(previousTurnEnd, turnEnd)
}
if (spans.length === 0) return null
return {
start: Math.min(...spans.map(span => span.start)),
end: Math.max(...spans.map(span => span.end)),

View File

@@ -27,6 +27,9 @@
}
.ledger {
position: relative;
z-index: 0;
isolation: isolate;
display: flex;
flex: 1;
min-height: 0;

View File

@@ -47,6 +47,7 @@ describe('tsdown client artifact', () => {
const modules = new Map<string, unknown>([
['react', await import('react')],
['react/jsx-runtime', await import('react/jsx-runtime')],
['@deepseek-ai/dsh-client-runtime/client', await import('@deepseek-ai/dsh-client-runtime/client')],
['@deepseek-ai/dsh-client-ui-primitives', await import('@deepseek-ai/dsh-client-ui-primitives')],
])
const surface = handoff!.factory((spec) => {

View File

@@ -184,7 +184,9 @@ describe('TrajectoryTable', () => {
expect(toolTag?.querySelector('[data-role-icon="wrench"]')).toBeTruthy()
fireEvent.mouseEnter(toolTag as HTMLElement)
expect(screen.getByRole('tooltip').textContent).toBe('TOOL')
const tooltip = screen.getByRole('tooltip')
expect(tooltip.textContent).toBe('TOOL')
expect(tooltip.getAttribute('data-side')).toBe('right')
fireEvent.mouseLeave(toolTag as HTMLElement)
expect(screen.queryByRole('tooltip')).toBeNull()
})

View File

@@ -31,6 +31,7 @@ import { TrajectoryTimeline } from '../src/client/TrajectoryTimeline.tsx'
import {
TrajectoryView, type TrajectoryViewInjected,
} from '../src/client/TrajectoryView.tsx'
import { createTrajectoryDurationStore } from '../src/client/duration-store.ts'
import { deriveTrajectoryTimeline } from '../src/client/timeline.ts'
const SID = 's1' as SessionId
@@ -96,6 +97,16 @@ function standaloneHistory(
}
}
function standaloneDuration(): Pick<
ComponentProps<typeof TrajectoryView>, 'useDuration' | 'setActualDuration'
> {
const duration = createSnapshotStore(false)
return {
useDuration: bindSnapshotSelector(duration),
setActualDuration: (value) => { duration.set(value) },
}
}
function fakeSession(nodes: ConversationSnapshot['nodes']) {
const store = createSnapshotStore({
nodes, pending: [], partial: null,
@@ -187,12 +198,15 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES
? {}
: injectEntry(SID)
const injectedProps = 'hooks' in injected
? {
loadAllHistory: (injected as TrajectoryViewInjected).loadAllHistory,
useHistory: bindSnapshotSelector(
(injected as TrajectoryViewInjected).hooks.history,
),
}
? (() => {
const trajectory = injected as TrajectoryViewInjected
return {
loadAllHistory: trajectory.loadAllHistory,
setActualDuration: trajectory.setActualDuration,
useHistory: bindSnapshotSelector(trajectory.hooks.history),
useDuration: bindSnapshotSelector(trajectory.hooks.duration),
}
})()
: injected
return (
<View
@@ -241,6 +255,24 @@ describe('plugin registration', () => {
await b.fiber.dispose()
expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat'])
})
it('shares one browser-wide duration preference across session injections', async () => {
const b = await bench()
const entry = b.slots.entries('conversation.view')
.find(candidate => candidate.options.id === 'trajectory')
expect(entry).toBeDefined()
const injectEntry = entry!.inject as unknown as (
sessionId: SessionId,
) => TrajectoryViewInjected
const first = injectEntry(SID)
const second = injectEntry('s2' as SessionId)
expect(second.hooks.duration).toBe(first.hooks.duration)
first.setActualDuration(true)
expect(second.hooks.duration.getSnapshot()).toBe(true)
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull()
})
})
describe('tab switching in ConversationRoot', () => {
@@ -460,6 +492,12 @@ describe('tab switching in ConversationRoot', () => {
fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' }))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
expect(screen.getByText('No timing data')).toBeTruthy()
expect(screen.getByRole<HTMLButtonElement>('button', {
name: 'Collapse turns',
}).disabled).toBe(false)
expect(screen.getByRole<HTMLButtonElement>('button', {
name: 'Collapse calls',
}).disabled).toBe(false)
expect(screen.queryByRole('row')).toBeNull()
expect(screen.queryByText(/turns ·/)).toBeNull()
})
@@ -490,6 +528,28 @@ describe('timeline projection', () => {
}],
}] satisfies readonly TrajectoryTurnModel[]
it('cancels native scrolling across the timeline while zooming', () => {
render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
range={null}
onRangeChange={vi.fn()}
/>,
)
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
toJSON: () => ({}),
})
expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
expect(fireEvent.wheel(screen.getByText('Input'), {
clientX: 20,
deltaY: -100,
})).toBe(false)
})
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
const onRangeChange = vi.fn()
const view = render(
@@ -542,7 +602,7 @@ describe('timeline projection', () => {
it('auto-pans a zoomed viewport while a range drag pushes against an edge', () => {
const onRangeChange = vi.fn()
render(
const view = render(
<TrajectoryTimeline
turns={longTurns}
mode="sequence"
@@ -560,13 +620,26 @@ describe('timeline projection', () => {
for (let index = 0; index < 24; index++) {
fireEvent.pointerMove(plot, { clientX: 99, pointerId: 1 })
}
const draftSelection = view.container.querySelectorAll<HTMLElement>(
'[data-dragging="true"]',
)
expect(draftSelection).toHaveLength(2)
for (const overlay of draftSelection) {
expect(Number.parseFloat(
overlay.style.getPropertyValue('--trajectory-selection-left'),
)).toBeLessThan(0)
}
fireEvent.pointerUp(plot, { clientX: 99, pointerId: 1 })
const selectedRange = onRangeChange.mock.calls.at(-1)?.[0] as
| { start: number; end: number }
| undefined
const fullRange = deriveTrajectoryTimeline(longTurns)
expect(selectedRange).toBeDefined()
expect(fullRange).not.toBeNull()
expect((selectedRange?.end ?? 0) - (selectedRange?.start ?? 0)).toBeGreaterThan(4)
expect(selectedRange?.start).toBeGreaterThanOrEqual(fullRange?.start ?? 0)
expect(selectedRange?.end).toBeLessThanOrEqual(fullRange?.end ?? 0)
})
it('uses equal-width operation slots and stable semantic lanes', () => {
@@ -657,6 +730,53 @@ describe('timeline projection', () => {
})
})
it('compresses every idle gap in duration mode while actual mode retains wall time', () => {
const separatedTurns = [
{
turn: 1,
groups: [{
title: 'Step 1',
cells: [
{ index: 1, kind: 'message', text: 'first', startedAt: 1_000, timeSeconds: 1 },
{ index: 2, kind: 'tool', text: 'within-turn gap', startedAt: 4_000, timeSeconds: 1 },
],
}],
},
{
turn: 2,
groups: [{
title: 'Step 1',
cells: [
{ index: 3, kind: 'message', text: 'after user idle', startedAt: 40_000, timeSeconds: 1 },
],
}],
},
] satisfies readonly TrajectoryTurnModel[]
expect(deriveTrajectoryTimeline(separatedTurns, 'duration')).toMatchObject({
start: 1_000,
end: 4_000,
spans: [
{ index: 1, start: 1_000, end: 2_000 },
{ index: 2, start: 2_000, end: 3_000 },
{ index: 3, start: 3_000, end: 4_000 },
],
turnBoundaries: [
{ turn: 1, time: 1_000 },
{ turn: 2, time: 3_000 },
],
})
expect(deriveTrajectoryTimeline(separatedTurns, 'actual')).toMatchObject({
start: 1_000,
end: 41_000,
spans: [
{ index: 1, start: 1_000, end: 2_000 },
{ index: 2, start: 4_000, end: 5_000 },
{ index: 3, start: 40_000, end: 41_000 },
],
})
})
it('projects between-turn compaction without inventing a turn boundary', () => {
const withStandaloneCompaction = [
{
@@ -702,6 +822,7 @@ describe('timeline projection', () => {
{
...standaloneProps([]),
...standaloneHistory(historySnapshot([])),
...standaloneDuration(),
},
))
expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy()
@@ -710,6 +831,38 @@ describe('timeline projection', () => {
})
describe('TrajectoryView branches', () => {
it('persists the duration preference through the runtime snapshot-store seam', () => {
const firstDuration = createTrajectoryDurationStore()
const commonProps = {
...standaloneProps(NODES),
...standaloneHistory(historySnapshot(NODES)),
}
const first = render(
<TrajectoryView
{...commonProps}
useDuration={bindSnapshotSelector(firstDuration)}
setActualDuration={(value) => { firstDuration.set(value) }}
/>,
)
const duration = screen.getByRole('button', { name: 'Use actual duration' })
expect(duration.getAttribute('aria-pressed')).toBe('false')
fireEvent.click(duration)
expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true')
first.unmount()
const restoredDuration = createTrajectoryDurationStore()
render(
<TrajectoryView
{...commonProps}
useDuration={bindSnapshotSelector(restoredDuration)}
setActualDuration={(value) => { restoredDuration.set(value) }}
/>,
)
expect(screen.getByRole('button', { name: 'Use actual duration' }).getAttribute('aria-pressed'))
.toBe('true')
})
it('renders only the selected rewind branch while retaining session-global requests', () => {
const retained = {
kind: 'user',
@@ -765,6 +918,7 @@ describe('TrajectoryView branches', () => {
const view = render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
/>,
@@ -807,6 +961,7 @@ describe('TrajectoryView branches', () => {
render(
<TrajectoryView
{...standaloneProps([])}
{...standaloneDuration()}
useHistory={bindSnapshotSelector(store)}
loadAllHistory={vi.fn(() => Promise.resolve())}
/>,

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tui/README.md
README.md: c81cac891403e5294c4456ce4d4048ecd74666ce
README.zh.md: 01055619f4df460284564f0a1816de366d809e01
README.md: 20ad471faa707cce0e6f0ce4df2289a900f09b93
README.zh.md: a7d918ba3239d3e6feaa2fb43181e4bcf33e1d89

View File

@@ -40,7 +40,7 @@ Selection repeats those checks and requires the current agent to be idle before
The exit line is launcher-owned, not configurable. A launcher provides `TUI_GOODBYE_MESSAGE_KEY` on the boot context — for the shipped `dsh`, the command that resumes this session — and exiting prints it verbatim after the terminal is released; absent, exiting prints nothing. Only the launcher knows how it was invoked, so only it can name a command that works. The TUI escapes terminal controls before rendering and never executes the text. A launcher that also supplies `MAIN_SESSION_ID_KEY` fixes which session the mounted app binds to, so resume survives any config-level patch.
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
A launcher can seed a fresh session's first turn by providing `INITIAL_SKILL_KEY` (the skill name) on the boot context; the TUI auto-invokes it exactly as a typed `/skill:<name>`, once the chat is live. The shipped `dsh migrate`/`dsh experimental-upgrade` set it and only for a fresh session, so a resumed session never re-invokes the skill; an unknown name is reported as a notice.
## Config

View File

@@ -40,7 +40,7 @@ Footer 将会话报告的用量汇总为 `↑<uncached input> ↓<output>`;任
退出时打印的行由启动器拥有,不可通过配置指定。启动器在启动上下文上提供 `TUI_GOODBYE_MESSAGE_KEY`(对于随附的 `dsh`,即恢复本会话的命令),释放终端后退出会原样打印它;未提供时退出不打印任何内容。只有启动器知道自己是如何被调用的,因此只有它能给出可用的命令。TUI 在渲染前会转义终端控制字符,且绝不执行该文本。若启动器同时提供 `MAIN_SESSION_ID_KEY`,则会固定已挂载应用绑定的会话,因此恢复功能不受配置层修补影响。
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`(skill 名称)来播种全新会话的首轮;聊天就绪后,TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh experimental-upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill;未知名称会以通知形式报告。
## 配置

View File

@@ -122,8 +122,8 @@ export interface Config extends TuiConfig {
/**
* Skill name auto-invoked as this session's first user turn, exactly as if
* the user typed `/skill:<name>`. Set only by a launcher for a fresh
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent leaves the first
* turn to the user.
* skill-guided session (`dsh migrate`/`dsh experimental-upgrade`); absent
* leaves the first turn to the user.
*/
initialSkill?: string
}

View File

@@ -227,9 +227,9 @@ export const TUI_GOODBYE_MESSAGE_KEY = 'tuiGoodbyeMessage'
/**
* Context key a launcher sets before any Loader entry mounts
* (`ctx.provide(INITIAL_SKILL_KEY, name)`) to seed a fresh session's first user
* turn with `/skill:<name>` — the `dsh migrate`/`dsh upgrade` guided-session
* entry. The launcher sets it only when minting a fresh session, so it never
* re-fires on a resumed one. Absent leaves the first turn to the user.
* turn with `/skill:<name>` — the `dsh migrate`/`dsh experimental-upgrade`
* guided-session entry. The launcher sets it only when minting a fresh session,
* so it never re-fires on a resumed one. Absent leaves the first turn to the user.
*/
export const INITIAL_SKILL_KEY = 'tuiInitialSkill'
@@ -1690,11 +1690,11 @@ export function createTuiChat(
})
startBannerReveal()
// A launcher-seeded first turn (`dsh migrate`/`dsh upgrade`): invoke the
// named skill exactly as a typed `/skill:<name>` would, once the chat is live
// and the agent is idle. The launcher sets this only for a fresh session, so
// there is no prior turn to collide with; invokeSkill reports an unknown skill
// as a notice.
// A launcher-seeded first turn (`dsh migrate`/`dsh experimental-upgrade`):
// invoke the named skill exactly as a typed `/skill:<name>` would, once the
// chat is live and the agent is idle. The launcher sets this only for a fresh
// session, so there is no prior turn to collide with; invokeSkill reports an
// unknown skill as a notice.
if (config.initialSkill !== undefined) invokeSkill(config.initialSkill, '')
return {