Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/host-contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
- `tab.visible` 语义是 `active && (...)`(全局面板 / 后台会话时 body 不挂载);持久化校验也收紧(非根 / 列轴 / 嵌套 / 3+ docked pane 的 split 一律判非法**整份丢弃**)。全局面板(根级 `main` keyed 槽 + `sidebar.panellist` + `ctx.layout.selectPanel/beginNavigation`、`rightbar` 改根级并新增 `rightbar.session`)插件**不接入**,只做兼容。底部工作台的中心列锚点认 `[data-slot="main.conversation"]`(并跳过 `display: contents` 祖先,同时保留 `[data-slot="conversation"]`)。原生 tab 体宿主 `.paneBody` 是**有确定高度的块级滚动容器**(非 flex 容器),native 适配层因此给每个 tab 体包一层 `height:100%` 的列 flex 宿主(`sidebar.module.css` 的 `.nativeTabHost`),tab 组件根继续用 `flex:1` / `height:100%`。
- **原生注册账本的两条不变量**(`tests/native-registration.spec.ts` 守护):① `live` 里同时装着描述符类型(键 = descriptor id)与 `files` 接管(键 = `FILES_KIND`,**永远不在 `wanted` 里**),清理循环必须**跳过 `FILES_KIND`**——它只由编辑器类型的 `wantsFiles/hasFiles` 开关分支与 seat disposer 管理;否则每次通知都会把它拆掉再重建,而 teardown 期间的那次重建会把 id 孤儿化:`tabs.register` 建在**宿主** ctx 上(宿主 ctx 此时仍活着)所以照样成功,紧随的 `ctx.slots.inject` 建在**插件** ctx 上却抛 `cannot create effect on inactive context`,`live.set` 永不执行 → 该 id 在整页生命周期内不可再注册(真机表现:`native register files error: … already registered` 刷屏 + 文件树落到宿主空态,只有刷新页面才恢复;实证见 [docs/plans/2026-09-28-native-files-takeover-reload-leak.md](plans/2026-09-28-native-files-takeover-reload-leak.md))。② 因此「注册成功一半」是**可达状态**:任何在宿主取走 id **之后**失败的注册(槽位注册抛错等)必须**回滚释放该 type** 并释放已经建好的槽位,插件侧只留一个下次通知可以重试的失败,绝不留下无处释放的 id。相关断言必须写在注册表的**事件日志**上而不是「活着的 id 集合」上:孤儿 id 会让键集合看起来完全正常,键集合断言在未修复代码上同样通过。
- **原生 tab 的状态保留(已采纳 `keepMounted: true`)**:宿主因此把访问过的 body 挂住,同会话切 tab 不再卸载,组件态(树滚动、未保存草稿、chip 重命名)随之免费保留,**不需要**任何 module-level 记忆。三条必须同时成立的配套事实:(a) **记录不能随卸载删除**(只由 `surface.close` 与「会话消失」回收),而「切回会话清零」那一路(真机日志里 `DISPOSE type …:files` → `register`、同键 `drop`+`mint`)已由上面的 `FILES_KIND` 守卫挡掉(真机 e2e 现断言跨会话 `scrollTop` 复原);(b) 原生 tab id **每会话各自计数**(`tab1`…)且多会话同号并存,记录与写面一律按 `sessionId::tabId` 记账,`Surface.update/has/activate` 的 `sessionId` 缺省按 `sidebarRight.mounted` 解析,多会话同名时**拒绝而不是猜**;(c) **展开集的权威在插件 store 的会话状态里**(`attachStore` + `syncExpanded` + `getSessionStates().expanded`,由 `native/index.ts` 绑定),记录只负责**身份**与投影——两套持久化会互相打架,插件不自持「每记录的展开集」。设计过程与实测见 [docs/plans/2026-09-25-native-tab-state-retention-keepmounted.md](plans/2026-09-25-native-tab-state-retention-keepmounted.md)。
10. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`),且 create 后必须 `inbox.clear()`、冷恢复必须按 id 精确清扫——标记对必要但不充分**:dsh-session 契约规定「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**。**但运行时 inbox 不走 `ownEvents()`**:`ReactLoopInbox.current()` → `ctx.sessionProjections.stateOf()` → `SessionProjectionRegistry.cellFor` 折的是 `snapshotEvents()` **全量日志(含继承种子前缀)**,且标准 inbox 投影的 `init` 忽略 `inheritedEventCount`、注册表 `session/created` 急切初始化跳过一切种子会话(seq ≠ 0)——种子里的 `agent/inbox/spliced` 会原样重放进子会话 live inbox。因此 sidechat 宿主 `agents.create` 成功后、任何 inject/followup 之前必须 `handle.agent.inbox.clear()`(durable 补偿 splice 是 own 事件,冷恢复折存储日志同样归零)。**幽灵的来源比「父会话排队」更宽**:父会话 mid-turn 且工具有未返回调用时,种子被截断在 `turn/start` 之前,而 inbox 的「领取」splice 发生在 `turn/start` **之后** → **父会话本轮开头已领取的输入**在种子折叠里仍是 pending;`clear()` 是唯一能一次清干净的硬围栏(create 时本线程自己不可能有排队输入)。**冷恢复不能沿用 `clear()`**(会误杀「用户在线程里排队、宿主重启后尚未领取」的合法输入):改为 `dropInheritedGhosts`——`inheritedPendingIds(log, inheritedEventCount)` 只折继承前缀得到幽灵 id 集合,再逐个 `agent.inbox.remove(id)`,精确且幂等(`src/sidechat-core.ts` 纯函数,`tests/sidechat-core.spec.ts` / `tests/sidechat-routes.spec.ts` 守护)。宿主 `session.fork`(api-session-controller)的调用即规范形态(注意:宿主自己的 `fork` **没有**这一步补偿,同窗口有同样的暴露面);回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折 + **真实 `SessionProjectionRegistry`** 折全量日志钉住上游洞与 clear 补偿)守护。**模型选择同为 agent 作用域**:`sidechat.model` 必须同时落 `session.append('model/selection', …)`(宿主 `selectForNextRequest` 的同一形状)与 `installModelSelection(agent.ctx, ref)`(`@deepseek-ai/dsh-agent` 公开导出),否则控制器缓存的 per-agent selection 永不回读投影;resume 后也要按日志里的有效选择重装(`src/sidechat-routes.ts` 的 `ensureModelSelection`)。**上游 0.2.0 新增的 `buildForkSeed` 与本插件手工的 `buildSidechatInheritance` 职责重叠,但收益不明确**(插件另有 dangling-tool-call 退回结构化快照的自有需求),暂不替换。
10. **自定义种子必须带 fork 标记对(`meta.isSeeded: true` + `inheritedEventCount`),且 create 后必须 `inbox.clear()`、冷恢复必须按 id 精确清扫——标记对必要但不充分**:dsh-session 契约规定「只给 seed 不标 isSeeded,种子算重放历史而非继承前缀」——缺标记时 `Session.ownEvents()` 含整个种子,子会话的 inbox 折重放种子里的 `agent/inbox/spliced`,**继承父会话切割时刻未领取的 inbox 输入**。**但运行时 inbox 不走 `ownEvents()`**:`ReactLoopInbox.current()` → `ctx.sessionProjections.stateOf()` → `SessionProjectionRegistry.cellFor` 折的是 `snapshotEvents()` **全量日志(含继承种子前缀)**,且标准 inbox 投影的 `init` 忽略 `inheritedEventCount`、注册表 `session/created` 急切初始化跳过一切种子会话(seq ≠ 0)——种子里的 `agent/inbox/spliced` 会原样重放进子会话 live inbox。因此 sidechat 宿主 `agents.create` 成功后、任何 inject/followup 之前必须 `handle.agent.inbox.clear()`(durable 补偿 splice 是 own 事件,冷恢复折存储日志同样归零)。**幽灵的来源比「父会话排队」更宽**:父会话 mid-turn 且工具有未返回调用时,种子被截断在 `turn/start` 之前,而 inbox 的「领取」splice 发生在 `turn/start` **之后** → **父会话本轮开头已领取的输入**在种子折叠里仍是 pending;`clear()` 是唯一能一次清干净的硬围栏(create 时本线程自己不可能有排队输入)。**冷恢复不能沿用 `clear()`**(会误杀「用户在线程里排队、宿主重启后尚未领取」的合法输入):改为 `dropInheritedGhosts`——`inheritedPendingIds(log, inheritedEventCount)` 只折继承前缀得到幽灵 id 集合,再逐个 `agent.inbox.remove(id)`,精确且幂等(`src/sidechat-core.ts` 纯函数,`tests/sidechat-core.spec.ts` / `tests/sidechat-routes.spec.ts` 守护)。宿主 `session.fork`(api-session-controller)的调用即规范形态(注意:宿主自己的 `fork` **没有**这一步补偿,同窗口有同样的暴露面);回归由 `tests/sidechat-seed-validation.spec.ts`(真实 `Session.create` + 对 `ownEvents()` 跑 inbox 折 + **真实 `SessionProjectionRegistry`** 折全量日志钉住上游洞与 clear 补偿)守护。**模型选择同为 agent 作用域**:`sidechat.model` 必须同时落 `session.append('model/selection', …)`(宿主 `selectForNextRequest` 的同一形状)与 `installModelSelection(agent.ctx, ref)`(`@deepseek-ai/dsh-agent` 公开导出),否则控制器缓存的 per-agent selection 永不回读投影;resume 后也要按日志里的有效选择重装(`src/sidechat-routes.ts` 的 `ensureModelSelection`)。**上游 0.2.0 新增的 `buildForkSeed` 与本插件手工的 `buildSidechatInheritance` 职责重叠,但收益不明确**(插件另有 dangling-tool-call 退回结构化快照的自有需求),暂不替换。 **切点位置与宿主 `buildForkSeed` 一致**:`inheritedEventCount` = 复制的父会话前缀长度,切点处放 `session/end-seed {inherited: true}`,本线程自己的 `working-directory/change` 与 `subagent/descriptor` 排在它之后。宿主只在 `events.slice(inheritedEventCount)` 里找子会话的 descriptor(冷恢复、持久化会话的 facts),descriptor 落在继承前缀内会让侧边对话被列为 corrupt、无法作为 continuable 子会话恢复;种子构造器只在切点处是该标记时才接受短于种子的切点(`tests/sidechat-routes.spec.ts` 用真实 `Session.create` + `foldSubagentDescriptor` 守护)。
11. **`dsh.plugin.json` 从来不是 DSH 契约**(全树 grep 0 命中):DSH 真正读的是 `package.json` 的 `dsh.bundle.patch` + `dsh.client`(`platform`/`inject`/`immediately`/`external`)+ `exports["./client"]`。`dsh.plugin.json` 与 `lib/client-registry.js` 只服务第三方 registry(社区市场)通道,`tests/manifest-consistency.spec.ts` 守护的是这条通道的形状,不是 DSH 契约。**不要混淆两个 manifest 的 `engines.dsh`**:当前宿主的兼容性检查优先读取 `package.json#engines.dsh`,存在时由它独立裁决;本插件未声明此字段,仍由 13 条 DSH peer 决定兼容性。`dsh.plugin.json#engines.dsh` 只是第三方 registry 元数据,不能替代 package 字段;`dsh.manifestVersion` 也不是版本兼容性开关。`dsh.bundle.patch` 有形状校验(非法值会 `throw`,引导期跳过该 bundle),本插件是合法的单字符串。
**插件展示元数据**(Plugins 页与 `plugin_manager` 列表,v0.21.1 起已采纳)三条硬规则:`package.json#icon` 必须是**包内相对路径**且写进 `files`(36×36、纯填充无描边、**双色渐变**——与 DSH 自带 `PluginArtwork*` 同风格,单色版在 30px 行内尺寸下会读成一块纯色板);`locale/en.json` 是发现基线(缺它整个目录都不读,`meta.title` / `description` 在这里);`exports` 必须暴露 `./locale/*.json` 与 `./package.json`。**所有失败都是静默的**——元数据有问题只会退化成一行纯文字,没有任何报错;`tests/plugin-meta.spec.ts` 守护「声明了就必须发布出去」。plugin-manager 侧另有**多 registry 回退**(配置源 → `fallbackRegistries`,默认含 `registry.npmmirror.com`;`network`/`timeout`/`not-found`/`no-matching-version` 会自动问下一个源;`registry` 入参只存在于 Web 对话框与 `plugin_manager` 工具,**CLI 没有 registry flag**)。
12. **宿主版本兼容性是启动期硬约束**:当前宿主先读插件 `package.json#engines.dsh`;字段存在时只按该范围判定,DSH peer 不再拓宽或收窄结果。字段缺席时回退检查名字为 `@deepseek-ai/dsh` 或以 `@deepseek-ai/dsh-` 开头的 peer,按 `semver.satisfies(宿主版本, range, { includePrerelease: true })` 判定。本插件刻意不新增该字段,仍由 13 条 DSH peer 的 `^0.2.1-rc.1` 支持线约束,optional peer 没有豁免。`engines` 非对象或 `engines.dsh` 非字符串会导致兼容性清单不可读并拒绝加载;`workspace:` 仅在 peer 中有效。安装与 profile 启动拒绝可能禁用整行,需读 `disabling profile plugin` 日志;拒绝记录字段已由 `peers` 改为 `requirements`,其中 key 为 `engines.dsh` 或回退检查的 peer 包名。`pnpm peers check` 校验的是 dev 依赖树,不能证明宿主启动预检通过。[官方升级指南](https://github.com/deepseek-ai/deepseek-harness/blob/dsh-v0.2.1-alpha.5/docs/upgrade-guide/v0.2.1-alpha.3/plugin-engines-dsh/guide.md)。
26 changes: 21 additions & 5 deletions src/sidechat-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -653,24 +653,40 @@ export function buildSidechatApi(ctx: Context, live?: AssistantLiveBuffer): Side
...(routeProvider === undefined ? {} : { agentProvider: routeProvider }),
...(routeModel === undefined ? {} : { agentModel: routeModel }),
})
// The inherited cut sits right after the copied parent history, the
// same shape as the host's own session.fork (buildForkSeed): the
// inherited prefix, an inherited end-seed marker AT the cut, then the
// thread's own events. The host reads a child's descriptor only from
// `events.slice(inheritedEventCount)` (subagent resume, persisted
// session facts); a descriptor inside the prefix leaves the thread
// listed as corrupt and not resumable as a continuable child. The
// seeded constructor accepts a cut shorter than the seed only when
// the event at the cut is this marker.
const inheritedEventCount = inheritance.seed.length
const endSeed: SeedEvent = {
type: 'session/end-seed',
seq: inheritedEventCount,
time: Date.now(),
data: { inherited: true },
}
// A truncated inheritance can end before the parent's last directory
// change. Header cwd alone cannot win: the host projection replays seed
// working-directory/change events over it. Stamp the committed directory
// at the seed boundary, using the host-owned event rather than refolding
// right after the cut, using the host-owned event rather than refolding
// its history or asking set() to enqueue a synthetic user notice.
const directorySeed: SeedEvent[] = workingDirectory === undefined ? [] : [{
type: 'working-directory/change',
seq: inheritance.seed.length,
seq: inheritedEventCount + 1,
time: Date.now(),
data: { cwd: parentCwd },
}]
const descriptorEvent: SeedEvent = {
type: 'subagent/descriptor',
seq: inheritance.seed.length + directorySeed.length,
seq: inheritedEventCount + 1 + directorySeed.length,
time: Date.now(),
data: descriptor as unknown as Record<string, unknown>,
}
const seed = [...inheritance.seed, ...directorySeed, descriptorEvent]
const seed = [...inheritance.seed, endSeed, ...directorySeed, descriptorEvent]
// Fork-marker fields (the exact shape the host's own session.fork uses,
// api-session-controller): without `isSeeded` + `inheritedEventCount` the
// session treats the whole seed as the child's OWN events, so the child's
Expand Down Expand Up @@ -710,7 +726,7 @@ export function buildSidechatApi(ctx: Context, live?: AssistantLiveBuffer): Side
// stamp, so importing the value aborts the whole host half at load time.
// `Array#length` is a non-negative safe integer by construction — exactly
// what the upstream stamp asserts — so the cast is lossless here.
inheritedEventCount: seed.length as SessionLogOffset,
inheritedEventCount: inheritedEventCount as SessionLogOffset,
agentOptions: childAgentOptions,
setup,
signal: AbortSignal.timeout(CREATE_TIMEOUT_MS),
Expand Down
2 changes: 1 addition & 1 deletion tests/host-runtime-imports.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,6 @@ describe('host runtime imports', () => {
const source = readFileSync(resolve(SRC, 'sidechat-routes.ts'), 'utf8')
expect(source).toContain(`import type { SessionEvent, SessionId, SessionLogOffset } from '${TYPE_ONLY_PACKAGE}'`)
// The cast carries the brand; the runtime stamp is what the host surface lacks.
expect(source).toContain('inheritedEventCount: seed.length as SessionLogOffset')
expect(source).toContain('inheritedEventCount: inheritedEventCount as SessionLogOffset')
})
})
Loading