Merge remote-tracking branch 'origin/master' into worktree-i18n-update-workflow

# Conflicts:
#	.agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.i18n.yaml
#	.agents/skills/dsh-translate-docs/SKILL.md
#	docs/i18n/README.i18n.yaml
This commit is contained in:
Tianyi Cui
2026-07-27 09:39:54 +08:00
827 changed files with 25749 additions and 9136 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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: 7a70add139eae7bc507469b4fe7170359efdec31
README.zh.md: 2d8ee677c71179df88211d90120a6017ceac8f6a

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-client-ui-subagent
English | [中文](README.zh.md)
Subagent reference source, browser half: registers the `@`-trigger `subagent` source into `ctx.slash`. Candidates are zero-RPC — filtered from the root `ctx.sessions.list` snapshot captured at registration (children of the per-call projection's session: `parentId` matches, `running`, `displayTitle` contains the query); picking a candidate lands the literal `@label ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` projects both faces as `@label` — the model serialization stays the raw label until the `@` consumption feature defines a model representation. The source implements no `matchSpace`/`matchEnter` hooks — subagent references never enter command adjudication and ride ordinary prompts into the default sink.
A session with no running children is simply candidate-less. This phase ships "menu + reference text" only; what consuming an `@label` means (steering the child, resuming a disposed one) is future business work.
The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
## Model Experience
### Subagent label text in the user prompt
#### What the model sees
A picked candidate lands the literal `@label` (the child session's display title) in the draft; the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side resolution. No consumption semantics exist yet: the model sees plain text and interprets it unaided.
#### Token effect
Conditional and tiny: only a pick (or hand-typing the same text) adds the label's characters to that one user message. Menu browsing adds zero model tokens (candidates never leave the browser).
#### KV Cache effect
Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens.
## Known Limitations and Deferred Work
- **`@` consumption semantics are unbuilt** — the reference is inert text; wiring it to steer/message the named child (and whether resuming a disposed child is allowed) awaits its own design decision in the ledger.
- **Candidates are running children only** — completed or disposed subagents never appear, and the roster is the scoped session's direct children (no grandchildren, no cross-session agents).
- **Labels are display titles, not stable ids** — two children sharing a display title produce indistinguishable references, and a title change orphans previously inserted text. Acceptable while references are inert; a consumption feature must bind to session ids.

View File

@@ -0,0 +1,31 @@
# @deepseek-ai/dsh-client-ui-subagent
[English](README.md) | 中文
subagent 引用 source 的浏览器半侧:把 `@` 触发的 `subagent` source 注册进 `ctx.slash`。候选零 RPC——从注册时捕获的根 `ctx.sessions.list` 快照过滤(每次调用的投影所指会话的子会话:`parentId` 匹配、`running``displayTitle` 包含 querypick 一个候选会把字面文本 `@label ` 经 slash 管线落进草稿(决策 21 的纯文本引用source 的 `codec` 把两种投影都产出为 `@label`——在 `@` 消费功能定义模型表示之前,模型序列化保持原始 label。source 不实现 `matchSpace``matchEnter` 钩子——subagent 引用永不进入命令裁决,随普通提示词落入 default sink。
没有运行中子会话的会话就是没有候选。本阶段只交付「菜单 + 引用文本」;消费一个 `@label` 意味着什么(对子会话做 steering中途引导、恢复已 dispose 的子会话)是未来的业务工作。
`/client` 导出表层只有插件主体(`apply``inject`source 对象是注册 effect 的内部实现。
## 模型体验
### 用户提示词中的 subagent label 文本
#### 模型所见
被 pick 的候选会把字面文本 `@label`(子会话的显示标题)落进草稿;该文本原样进入普通用户消息(`session.prompt`)到达模型,没有专用内容块、提示词 section 或 host 侧解析。目前不存在任何消费语义:模型看到的是纯文本,只能自行解读。
#### Token 影响
有条件且极小:只有 pick或手动键入相同文本会把 label 的字符加进那一条用户消息。浏览菜单增加零模型 token候选永不离开浏览器
#### KV Cache 影响
仅追加:引用是追加在可复用历史前缀之后的新用户消息的一部分。该包绝不改写较早的请求 token。
## 已知限制与暂缓事项
- **`@` 消费语义尚未构建**:引用只是惰性文本;把它接到对指名子会话的 steering发消息以及是否允许恢复已 dispose 的子会话),等待台账中它自己的设计决策。
- **候选只有运行中的子会话**:已完成或已 dispose 的 subagent 永不出现roster 只含 scope 所指会话的直接子会话(不含孙辈,不含跨会话 agent
- **label 是显示标题,不是稳定 id**:两个子会话共用一个显示标题时,产生的引用无法区分;标题变更会使先前插入的文本失去指向。引用还是惰性文本时可以接受;消费功能必须绑定到会话 id。

View File

@@ -0,0 +1,59 @@
{
"name": "@deepseek-ai/dsh-client-ui-subagent",
"description": "Subagent reference source: '@' menu candidates from the session snapshot (zero RPC), inserts @label references",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-slash"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View File

@@ -0,0 +1,58 @@
/**
* Subagent reference plugin, browser half: registers the '@' source —
* candidates filtered from the session list snapshot's running children
* (zero RPC; the list rides the plugin's root-context sessions service, the
* scoped session comes from the per-call projection), pick inserts the
* literal `@label ` text (decision 21: the draft carries plain text, chip
* visuals are derived by scanning against the source lexicon, and the
* prompt ships the same literal). Consumption semantics stay with future
* business work (design ledger). No adjudication hooks: subagent
* references never enter command adjudication.
*/
import type { ClientContext, SessionsService } from '@deepseek-ai/dsh-client-runtime/client'
import type { ClientSessionContext, SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
/** Required services: the slash registry + the session list face the source closes over. */
export const inject = ['slash', 'sessions']
/**
* Client plugin body: register the '@' subagent source over the root session list.
* @param ctx - client root context.
*/
export function apply(ctx: ClientContext): void {
const sessions = ctx.get('sessions') as SessionsService
// Child labels live on the session list (parentId lineage + displayTitle),
// not the conversation snapshot — the list store is the zero-RPC candidate feed.
const childLabels = (session: ClientSessionContext, query: string): string[] => {
const { byId } = sessions.list.getSnapshot()
return Object.values(byId)
.filter(child => child.parentId === session.sessionId && child.running && child.displayTitle.includes(query))
.map(child => child.displayTitle)
}
const source: SlashSource = {
trigger: '@',
name: 'subagent',
candidates(session, { query }) {
return Promise.resolve(childLabels(session, query).map(name => ({ name })))
},
lexicon(session) {
// The list snapshot is always warm — the full running-children roster.
return childLabels(session, '')
},
onPick({ candidate }) {
// Decision 21: plain-text reference — the literal lands in the draft
// and ships to the model verbatim (trailing space closes the token).
// Legacy path (decision 21), retained for the removal cut, no longer reached:
// return { insert: { source: 'subagent', ref: candidate.name, label: candidate.name, clipboardText: `@${candidate.name}` } }
return { text: `@${candidate.name} ` }
},
codec: {
clipboardText: ref => `@${ref}`,
// TODO: serialize returns the raw label until the '@' consumption
// feature defines a model representation (design ledger).
serialize: ref => Promise.resolve(`@${ref}`),
},
}
const slash = ctx.get('slash') as SlashServiceContract
ctx.effect(() => slash.registerSource(source), 'ui-subagent: @ source')
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View File

@@ -0,0 +1,9 @@
/**
* Subagent reference plugin, node half. Pure UI plugin: the empty apply
* exists so the plugin appears in the host cordis.yml / Loader; the browser
* half ships via exports["./client"], discovered through the package.json
* dshClient declaration.
*/
/** Host plugin body — no host-side behavior for this source plugin. */
export function apply(): void {}

View File

@@ -0,0 +1,31 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-subagent`.
* @module @deepseek-ai/dsh-client-ui-subagent/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-subagent'
/** Cordis companion plugin name. */
export const name = 'client-ui-subagent-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: a single slash-source registration whose disposal is
* proven by the HMR-safety spec — it emits no cordis events and owns no
* cross-plugin mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,145 @@
/**
* ui-subagent browser half: source registration (duplicate-name proof) +
* fiber-teardown removal (HMR safety) against the real SlashService, then
* the source behavior contract driven directly on the captured source with
* real ClientSessionContext projections — zero-RPC candidates from the root
* session list (running children of the projected session, label-contains
* filtering, childless session → empty), the synchronous lexicon roster,
* pick → plain-text outcome (decision 21), and the reference codec's two
* projections. Direct driving is deliberate: this spec owns only the
* source's own contract.
*/
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { SessionId, SessionListState, SessionSummary } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import { apply, inject } from '../src/client/index.ts'
function summary(partial: Partial<SessionSummary> & { id: SessionId }): SessionSummary {
return {
displayTitle: partial.id,
running: false,
updatedAt: 0,
...partial,
}
}
const sid = (id: string) => id as SessionId
/** Fake root sessions face: the list snapshot the source closes over. */
function sessionsWith(sessions: SessionSummary[]) {
const byId: Record<string, SessionSummary> = {}
for (const s of sessions) byId[s.id] = s
const snapshot = { ids: sessions.map(s => s.id), byId, current: undefined } as unknown as SessionListState
return { list: { getSnapshot: () => snapshot } }
}
/** Boot the plugin over fake slash/sessions faces; returns the captured source. */
async function bench(sessions: SessionSummary[]): Promise<SlashSource> {
const ctx = new Context()
let captured: SlashSource | undefined
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('sessions', sessionsWith(sessions))
await ctx.plugin({ inject: [...inject], apply }).await()
return captured!
}
const FAMILY: SessionSummary[] = [
summary({ id: sid('parent'), displayTitle: 'parent', running: true }),
summary({ id: sid('c1'), parentId: sid('parent'), displayTitle: 'worker-1', running: true }),
summary({ id: sid('c2'), parentId: sid('parent'), displayTitle: 'worker-2', running: true }),
// Filtered out: not running / other parent / label miss.
summary({ id: sid('c3'), parentId: sid('parent'), displayTitle: 'worker-3', running: false }),
summary({ id: sid('c4'), parentId: sid('other'), displayTitle: 'worker-4', running: true }),
summary({ id: sid('c5'), parentId: sid('parent'), displayTitle: 'scout', running: true }),
]
const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
const req = (query: string) =>
({ query, position: 'inline' as const, signal: new AbortController().signal })
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'sessions'])
})
it('registers the "@" subagent source; disposal frees the name (HMR safety)', async () => {
const ctx = new Context()
await ctx.plugin(SlashService).await()
ctx.provide('sessions', sessionsWith(FAMILY))
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const slash = ctx.get('slash') as SlashService
const rival = {
trigger: '@' as const,
name: 'subagent',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
}
// Live registration holds the (trigger, name) seat…
expect(() => slash.registerSource(rival)).toThrow(/already registered/)
// …and fiber teardown releases it.
await fiber.dispose()
expect(() => slash.registerSource(rival)).not.toThrow()
})
})
describe('candidates', () => {
it('returns running children of the projected session, filtered by label containment', async () => {
const source = await bench(FAMILY)
await expect(source.candidates(proj('parent'), req('worker'))).resolves.toEqual([
{ name: 'worker-1' }, { name: 'worker-2' },
])
})
it('matches every running child on an empty query (containment, not prefix)', async () => {
const source = await bench(FAMILY)
await expect(source.candidates(proj('parent'), req(''))).resolves.toEqual([
{ name: 'worker-1' }, { name: 'worker-2' }, { name: 'scout' },
])
})
it('is candidate-less for a session with no children', async () => {
const source = await bench(FAMILY)
await expect(source.candidates(proj('childless'), req(''))).resolves.toEqual([])
})
})
describe('lexicon', () => {
it('synchronously serves the projected session\'s full running-children roster', async () => {
const source = await bench(FAMILY)
expect(source.lexicon!(proj('parent'))).toEqual(['worker-1', 'worker-2', 'scout'])
expect(source.lexicon!(proj('childless'))).toEqual([])
})
})
describe('pick and codec', () => {
it('onPick returns the literal @label text with a closing space (decision 21)', async () => {
const source = await bench(FAMILY)
const outcome = source.onPick({
candidate: { name: 'worker-1' },
session: proj('parent'),
position: 'inline',
via: 'menu',
span: { start: 4, end: 8, draftRev: 3 },
})
expect(outcome).toEqual({ text: '@worker-1 ' })
})
it('codec projects clipboard `@label` and serializes the same raw label this phase', async () => {
const source = await bench(FAMILY)
expect(source.codec!.clipboardText('worker-1')).toBe('@worker-1')
await expect(source.codec!.serialize('worker-1', new AbortController().signal))
.resolves.toBe('@worker-1')
})
})
describe('adjudication', () => {
it('never participates: no matchSpace/matchEnter hooks on the subagent source', async () => {
const source = await bench(FAMILY)
expect('matchSpace' in source && source.matchSpace !== undefined).toBe(false)
expect('matchEnter' in source && source.matchEnter !== undefined).toBe(false)
})
})

View File

@@ -0,0 +1,27 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../runtime"
},
{
"path": "../ui-slash"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-subagent', ['lib/types/index.js', 'lib/types/invariant.js'])