merge: refresh against the advancing master

This commit is contained in:
Huanqi Cao
2026-08-01 19:41:44 +08:00
62 changed files with 806 additions and 173 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 packages/client/ui-goal/README.md
README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2
README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9
README.md: cfb54fd28044ed80e6ec05de0be057f5d4cfaf46
README.zh.md: fd999cf1c4c9695d15cfaab3e83afdf475f40448

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing.
Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no domain store, refresh chain, or event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline. The strip single-flights mutations synchronously because React's pending render cannot fence same-frame clicks; after a successful clear it immediately suppresses that exact goal id while the authoritative null projection catches up. Goal creation stays on the `/goal` host command; loading, absent, completed, and successfully cleared goals render nothing.
The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types.
@@ -17,4 +17,3 @@ None beyond the goal mutation's own context event, which appends to the log tail
## Known Limitations and Deferred Work
- **Durable phase only** — the projection value deliberately omits process-local activation (armed/disarmed), so the strip cannot distinguish an active-but-disarmed goal from an armed one; resume re-arms through the RPC side. A host-live-value channel is deferred until a real consumer needs it.
- **No keyless snapshot yet** — the assembled-application transcript (boot → projection → GoalBar) is deferred to the post-review cleanup pass recorded on the landing PR.

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片order 0位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。
Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片order 0位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有领域 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词edit / pause / resume / clear`goal.*` 协议域——active 的 goal 提供暂停动作paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref并把结算后的 RPC 错误内联呈现。由于 React 的 pending 渲染无法拦住同一帧内的点击,横条会同步为变更建立 single-flight 防护;清除成功后,会立即抑制该 goal id 对应的目标显示,直到权威的 null 投影追上。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成和已成功清除的 goal 一律不渲染。
`/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。
@@ -17,4 +17,3 @@ Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input
## Known Limitations and Deferred Work
- **只反映持久 phase** —— 投影值有意省略进程本地的 activationarmed/disarmed条带无法区分 active-but-disarmed 与 armed 状态resume 经 RPC 侧重新武装。host 活值通道待出现真实消费方后再议。
- **暂缺 keyless 快照** —— 组装应用级 transcriptboot → 投影 → GoalBar推迟到落地 PR 记录的评审后收口批次。

View File

@@ -8,7 +8,7 @@
* the injected face.
*/
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import {
IconCheckOutline16, IconCloseOutline16, IconEditOutline16, IconPauseOutline16, IconPlayOutline16, IconSparkle16, IconTrashOutline16,
@@ -35,6 +35,8 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
const [draft, setDraft] = useState('')
const [pending, setPending] = useState(false)
const [actionError, setActionError] = useState<string | null>(null)
const [clearedGoalId, setClearedGoalId] = useState<GoalSnapshot['id'] | null>(null)
const pendingRef = useRef(false)
// A new goal identity (cleared/completed/replaced externally) invalidates the local edit
// state: without the reset a surviving draft's Enter would write over the NEW goal.
@@ -42,32 +44,37 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
useEffect(() => {
setEditing(false)
setActionError(null)
setClearedGoalId(null)
}, [goalId])
// React state disables the controls on the next render; the ref closes the
// same-render window so rapid clicks cannot submit the same CAS twice.
const runAction = useCallback(async (action: () => Promise<GoalActionResult>): Promise<GoalActionResult | undefined> => {
if (pendingRef.current) return undefined
pendingRef.current = true
setPending(true)
setActionError(null)
const result = await action()
pendingRef.current = false
setPending(false)
if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`)
return result
}, [])
const handleEdit = useCallback(async () => {
const trimmed = draft.trim()
if (trimmed === '') return
setPending(true)
setActionError(null)
const result = await onEdit(trimmed)
setPending(false)
if (result.ok) {
setEditing(false)
} else {
setActionError(`${result.error.message} (${result.error.code})`)
}
}, [draft, onEdit])
const result = await runAction(() => onEdit(trimmed))
if (result?.ok) setEditing(false)
}, [draft, onEdit, runAction])
const runAction = useCallback(async (action: () => Promise<GoalActionResult>) => {
setPending(true)
setActionError(null)
const result = await action()
setPending(false)
if (!result.ok) setActionError(`${result.error.message} (${result.error.code})`)
}, [])
const handleClear = useCallback(async (clearedId: GoalSnapshot['id']) => {
const result = await runAction(onClear)
if (result?.ok) setClearedGoalId(clearedId)
}, [onClear, runAction])
// Loading, absent, and complete goals have no strip at all.
if (goal === undefined || goal === null || goal.phase === 'complete') return null
if (goal === undefined || goal === null || goal.phase === 'complete' || goal.id === clearedGoalId) return null
if (editing) {
return (
@@ -142,7 +149,7 @@ export function GoalBar({ goal, onEdit, onPause, onResume, onClear, t }: GoalBar
>
<IconEditOutline16 />
</button>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onClear) }} title={t('action.clear')} aria-label={t('action.clear')}>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void handleClear(goal.id) }} title={t('action.clear')} aria-label={t('action.clear')}>
<IconTrashOutline16 />
</button>
</div>

View File

@@ -3,13 +3,13 @@
// inline edit form, and resume/clear icon actions — driven purely through
// props, no wire. Loading, absent, and complete goals render nothing.
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { GoalSnapshot } from '@deepseek-ai/dsh-goal/client'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
import { GoalBar } from '../src/client/GoalBar.tsx'
import type { GoalBarActions } from '../src/client/slots.ts'
import type { GoalActionResult, GoalBarActions } from '../src/client/slots.ts'
import { zh } from '../src/client/locales.ts'
// The framework-injected t seat, stubbed over the zh dictionaries (the default locale).
@@ -61,6 +61,27 @@ describe('GoalBar', () => {
expect(actions.onClear).toHaveBeenCalledTimes(1)
})
it('single-flights rapid clear clicks and hides the committed goal before its projection catches up', async () => {
const actions = makeActions()
let resolveClear!: (result: GoalActionResult) => void
actions.onClear.mockImplementation(() => new Promise((resolve) => { resolveClear = resolve }))
const { container, rerender } = render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
const clear = screen.getByRole<HTMLButtonElement>('button', { name: '清除目标' })
act(() => {
clear.click()
clear.click()
})
expect(actions.onClear).toHaveBeenCalledTimes(1)
expect(clear.disabled).toBe(true)
await act(async () => { resolveClear({ ok: true }) })
expect(container.firstChild).toBeNull()
rerender(<GoalBar goal={makeGoal({ id: 'g2' as GoalSnapshot['id'], objective: 'Next goal' })} {...actions} t={t} />)
expect(screen.getByText('Next goal')).toBeTruthy()
})
it('edit swaps the strip for a prefilled form; Enter saves, empty stays disabled', async () => {
const actions = makeActions()
render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
@@ -180,5 +201,7 @@ describe('GoalBar', () => {
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
expect((await screen.findByRole('alert')).textContent).toBe('clear failed (agent-busy)')
expect(screen.getByText('Ship the redesign')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '清除目标' }))
await waitFor(() => { expect(actions.onClear).toHaveBeenCalledTimes(2) })
})
})

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/app-boot/README.md
README.md: ebd8e0842b934f6887e3c122e781c1d0f13bb5d3
README.zh.md: ccd897d48178482aa74d0eb73505e26ec3a08d6c
README.md: efc8c42e192a02ecf37f8ea1356aa22261c70d0e
README.zh.md: 927d6d1fb493c404fcdbe14f1c668b1743412ea5

View File

@@ -15,7 +15,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c
| `loadOverlayPatches(binName, file)` | Parse a required patch-list file with the same shape as personal config; read or parse failures throw a labelled error |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount and await the include tree, assert entries loaded and activated, and return the root context |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline — the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts — and render YAML with `!!js` expressions verbatim; each run of same-provenance rows is preceded by a `# ==` comment naming the contributing file and the layers that patched it, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), read/parse/shape failures throw |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |
Two Loader failure classes require separate guards because tree settlement propagates neither to its caller. A failed plugin import leaves a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection naming every unresolved plugin. A plugin callback or config failure leaves a failed fiber because `loader.await()` settles lifecycle tasks without propagating that error; `assertEntriesActivated` awaits the fiber explicitly and includes its original stack in the startup rejection. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal.

View File

@@ -15,7 +15,7 @@
| `loadOverlayPatches(binName, file)` | 解析一份必需的 patch 列表文件,其形状与个人配置相同;读取或解析失败时抛出带标签的错误 |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader在配置树条目挂载前执行可选的宿主准备操作`prepare` 可以使用 Loader也可以提供由启动器拥有的上下文插槽例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文 |
| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 离线合成基础配置与带标签的覆盖层——使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`),因此结果与 `boot()` 挂载的内容一致——并渲染为 YAML`!!js` 表达式原样保留;每段来源相同的连续行之前都有一条 `# ==` 注释,标明贡献该段的文件以及修补过它的层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr读取解析形状失败则抛出 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent智能体自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber因此开发环境 HMR热模块替换重新加载系统提示词后它会消失直至下次启动 |
| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent智能体DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber因此开发环境 HMR热模块替换重新加载系统提示词后它会消失直至下次启动 |
| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 |
Loader 树结算不会向调用方传播两类故障,因此需要分别保护。插件导入失败会留下没有 fiber 的配置项,`assertEntriesLoaded` 将其转换为 `boot()` rejection并列出每个未解析插件。插件回调或配置失败则会留下失败的 fiber因为 `loader.await()` 只结算生命周期任务,不传播该错误;`assertEntriesActivated` 会显式等待该 fiber并把原始错误堆栈写入启动 rejection。抛出错误前审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。

View File

@@ -480,11 +480,11 @@ export async function boot(
export const HARNESS_SOURCE_SECTION = 'harness:source'
/**
* Add a global prompt section naming the on-disk path to the harness source
* checkout the running bin was launched from, so the agent knows where its own
* source lives (the self-referential `dsh-tool-cordis` toolset reads and edits
* it). Call once on the settled boot context ({@link boot}); the section orders
* just after the harness identity opener (`-100`) and before the deployment
* Add a global prompt section naming the on-disk harness source checkout while
* explicitly distinguishing it from the task workspace and current working
* directory. The self-referential `dsh-tool-cordis` toolset reads and edits this
* checkout. Call once on the settled boot context ({@link boot}); the section
* orders just after the harness identity opener (`-100`) and before the deployment
* persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
* augment, so this is then a no-op that returns `undefined`. The section is
* registered against the `systemPrompt` service's fiber, so a dev HMR reload of
@@ -499,6 +499,6 @@ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() =
return systemPrompt.section({
name: HARNESS_SOURCE_SECTION,
order: -99,
text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`,
text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`,
})
}

View File

@@ -391,9 +391,9 @@ describe('boot', () => {
describe('addHarnessSourceSection', () => {
const SOURCE_ROOT = `${sep}opt${sep}harness-src`
const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
const EXPECTED = `The DeepSeek Harness implementation checkout is at ${SOURCE_ROOT}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`
it('adds the source path between the harness identity and the deployment persona', async () => {
it('distinguishes the source path from the current workdir between identity and persona', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })

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: 20ad471faa707cce0e6f0ce4df2289a900f09b93
README.zh.md: a7d918ba3239d3e6feaa2fb43181e4bcf33e1d89
README.md: c81cac891403e5294c4456ce4d4048ecd74666ce
README.zh.md: 01055619f4df460284564f0a1816de366d809e01

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 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.
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.
## 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 experimental-upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill未知名称会以通知形式报告。
启动器可通过在启动上下文上提供 `INITIAL_SKILL_KEY`skill 名称来播种全新会话的首轮聊天就绪后TUI 会像用户手动键入 `/skill:<name>` 一样自动调用它。随附的 `dsh migrate`/`dsh upgrade` 会设置该键,且仅对全新会话设置,因此恢复的会话绝不会重复调用该 skill未知名称会以通知形式报告。
## 配置

View File

@@ -122,7 +122,7 @@ 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 experimental-upgrade`); absent
* skill-guided session (`dsh migrate`/`dsh upgrade`); absent
* leaves the first turn to the user.
*/
initialSkill?: string

View File

@@ -227,7 +227,7 @@ 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 experimental-upgrade`
* 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.
*/
@@ -1690,7 +1690,7 @@ export function createTuiChat(
})
startBannerReveal()
// A launcher-seeded first turn (`dsh migrate`/`dsh experimental-upgrade`):
// 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