Files
deepseek-harness/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md
T
Turtle f290a8b851 refactor(cli)!: one shared base config with per-surface overlays
`dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml
composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml
whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a
twenty-key pass-through Config. Neither file was what its location claimed —
apps/cli hardcoded the "example" as the product default and the "demo" bundle
was the application — and every capability change had to be made twice.

- apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and
  web.cordis.yml are patch lists stating only what differs per surface
- overlays apply as SIBLING patch lists at one include level, because include
  patches never cross an include boundary. Precedence: base < surface <
  (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches
- `--config` now applies an overlay INSTEAD OF the personal one, so a demo or
  test tree never inherits the user's route; new `--config-replace` boots a file
  as the entire tree (the old `--config` behaviour). Both survive /resume
- vendor/include: index each `insert`ed row as it is added so a later patch can
  configure or disable it. Upstream built the id index once before the patch
  loop, leaving every surface-only row — the whole TUI front door — silently
  unpatchable from user config. Logged as local modification 8
- session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY;
  dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it)
- delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo;
  TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests,
  examples/code-mode survives as an overlay leaf
- `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay

Three latent defects surfaced and are fixed here: the TUI captured the optional
sessionQuery service once at construction and could permanently disable /resume
when it won the mount race; the session-store root silently reverted to a
project-local ./.sessions; --config-replace was dropped by the resume handoff.

Verified by booting each tree through the real Loader (TUI 55 entries, web 75,
zero unsettled) rather than reading YAML. All eight terminal snapshots replay
byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene
and lint clean.
2026-07-29 21:15:42 +08:00

5.3 KiB

Agent Note: dsh migrate/dsh upgrade seed the first turn with a skill

Status: implemented

English | 中文

Problem

Two recurring flows begin with the user manually invoking one skill and answering its questions: migrating from another coding agent, and upgrading this checkout. Both require the user to know the skill exists and to type /skill:dsh-migrate or /skill:dsh-upgrade as the session's first turn. A dedicated entry command that drops the user straight into that guided session removes the discovery step.

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.

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.

Freshness is gated in the launcher, not the TUI. runSkillSession always mints a fresh session and provides the slot only when resumeSessionId === undefined, so a later dsh --resume <id> of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup.

migrate/upgrade take no options. Unlike meta, they carry no --resume, --config, or -p; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the web/meta rejection pattern in the Commander adapter. The two modes share one SkillSessionInvocation discriminant (mode: 'migrate' | 'upgrade'); bin.ts maps the mode to dsh-${mode}.

The dsh-migrate skill is bundled under skills/ (shipped through DSH_BUNDLED_SKILL_DIR, like dsh-upgrade). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the hooks-claude/hooks-codex bridges, ~/.dsh/{config.yaml,.env,AGENTS.md,skills/}, AGENTS.md/CLAUDE.md, mcporter), and states plainly when a capability has no equivalent.

Testing

apps/cli/tests/args.spec.ts gains routing for migrate/upgrade (bare discriminant) and exit-1 for every leaked option on either side of each subcommand.

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.

Alternatives considered

Prefill the input and let the user press Enter. Rejected: needs a new editor-prefill seam and still requires a keystroke. Auto-submit reuses invokeSkill and delivers the intended one-command entry.

Seed a natural-language instruction ("use the dsh-migrate skill…") instead of /skill:<name>. Rejected here: the literal skill-invocation path renders the skill body into the first turn deterministically, identical to the manual command, rather than depending on the model choosing to load the skill.

Support --resume on migrate/upgrade. Rejected: these are one-shot guided entries. A resumed session is an ordinary TUI session reachable through the default surface's dsh --resume <id>; re-injecting the skill on resume would duplicate the first turn.

Read INITIAL_SKILL_KEY outside the TUI (as CONFIGURED_AGENT_IDENTITIES_KEY is read by agent-loop) rather than in the TUI's apply(). Not needed: initialSkill is a TUI Config field consumed in createTuiChat, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (tuiResumeHost, tuiGoodbyeMessage) and touches no other plugin.

Consequences

Migrating or upgrading is one command from anywhere, with the guiding skill already invoked. The launcher→TUI initial-skill slot is reusable by any future guided-session command; the TUI's contract is "invoke this named skill once at startup," and freshness/resume policy stays with the launcher that owns session identity. The TUI skill slash command remains the mechanism; this note adds a launcher-driven auto-invocation of it and does not supersede it.