Merge remote-tracking branch 'origin/master' into feat/unify-code-highlight-languages

This commit is contained in:
mektpoy committed 2026-09-23 15:39:36 +08:00
commit 8b75ec9903
124 files changed
+2645 -1142

No files matched your search

@@ -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-09-15-guided-plugin-installation.md
2026-09-15-guided-plugin-installation.md: 26ad870856ce948e1ebb736a07004e41af2a17be
2026-09-15-guided-plugin-installation.zh.md: 36e76c8ace0ae990ede14436b65fa00752f86bbe
2026-09-15-guided-plugin-installation.md: 451b1f316eebfdce9c9efcffbf48202177d04343
2026-09-15-guided-plugin-installation.zh.md: 0c228695828238b4dbfdae7d2153e0358e92360c
@@ -16,6 +16,8 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
**A failed run is classified where the facts are.** `classifyInstallFailure` reads how the run ended and what pnpm printed — its `ERR_PNPM_*` codes and Node's errno names — into `packageResult.kind`. The client shows the kind as one line and folds pnpm's output behind the details. Parsing the log is confined to this one function with a fixture-driven test.
**GitHub connection failures have a short budget before installation.** The Host runs a read-only `git ls-remote` check with the profile's Git and proxy configuration and a default `githubConnectionTimeoutMs` of 5000 ms. Credential helpers and prompts are disabled for this check, so authentication cannot consume the connection budget. Only network failures and timeouts prevent pnpm from starting; pnpm still owns authentication, HTTPS-to-SSH fallback, ref resolution and the full installation. The budget does not limit downloads or builds. The existing failure kind and `failedAt: 'spec-host'` let the Client show a connection error or timeout and offer a domestic registry for a replacement package spec. Cancellation and manager disposal terminate the check and its descendants before settling.
**Cancellation is the manager's, not the signal's.** The dialog generates a request id for each run; the Host streams the run's output and phases under it, and `cancelInstall` answers only after pnpm exited and the files are back. The dialog waits for that answer before offering the spec again; an aborted RPC or a lost connection is not a confirmation. Only the check takes a trailing `AbortSignal`: going back or closing drops a registry lookup, whose settlement nothing waits for.
**Enabling comes after the fact.** The run installs with `enabled: false`; the finished screen offers **Enable now** for the bundle it added, and the dialog closes and the list scrolls to it. Nothing is enabled before the person has seen what was installed.
@@ -36,8 +38,10 @@ The install dialog put a spec straight into `pnpm add` and showed pnpm's termina
## Consequences
`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand` and `inspectTimeoutMs`. The dialog is four screens over one subject card.
`inspect`, `cancelInstall`, and the `plugin-manager/changed`, `plugin-manager/install-log`, and `plugin-manager/install-state` events join the manager's Remote; `listBundles` carries titles, rows, and overrides; `ChangeResult` gains `cancelled`, `bundle`, and `packageResult.kind`; the config gains `pnpmCommand`, `inspectTimeoutMs` and `githubConnectionTimeoutMs`. The dialog is four screens over one subject card.
## Testing
`packages/boot/plugin-manager/tests/install-spec.spec.ts` pins the spec forms and the failure classifier's inputs; `manager.spec.ts` drives `inspect` against a stubbed registry lookup and a real directory, streams a run, stops one and checks the restored files, and checks the change events; `operations.spec.ts` covers the registry lookup. `packages/client/ui-plugin-manager/tests` cover the store's phases, Host-confirmed cancellation, post-install enabling, toasts, and the page's four screens; `apps/web/tests/plugin-manager.e2e.ts` refuses an installed name, a missing path, and a bad name through the real Host and switches a bundle and one of its rows live, and `plugin-install-cancel.e2e.ts` stops a real child from the dialog, checks the restored files, and installs on the second try, and `plugin-install-approve.e2e.ts` leaves a script undecided through the fake pnpm, allows it from the dialog, and installs on the retry.
`github-connection.spec.ts` uses real Git against owned loopback transports to cover immediate failure, timeout, cancellation and noninteractive authentication. `manager.spec.ts` also installs a local repository through real Git and pnpm for HTTPS, GitHub shorthand and SSH specs, isolating pnpm's HTTPS probe through an owned proxy and verifying its SSH fallback. `plugin-install-github.e2e.ts` checks the connection error and timeout dialogs, child cleanup, and switching to a mirror without retrying the old GitHub address.
@@ -16,6 +16,8 @@ Status: implemented
**失败在事实所在处分类。** `classifyInstallFailure` 依据运行的结束方式和 pnpm 的输出——它的 `ERR_PNPM_*` 码和 Node 的 errno 名——给出 `packageResult.kind`。客户端把 kind 显示成一句话,把 pnpm 输出折叠进详情。对日志的解析只存在于这一个函数,用夹具驱动的测试钉住。
**GitHub 连接失败在安装前有单独的短时限。** 宿主通过只读的 `git ls-remote` 检查连接,沿用 profile 的 Git 与代理配置,`githubConnectionTimeoutMs` 默认为 5000 毫秒。这次检查禁用凭据助手和认证提示,避免认证过程占用连接预算。只有网络失败与超时会阻止 pnpm 启动;认证、HTTPS 到 SSH 的回退、ref 解析及完整安装仍由 pnpm 负责。时限不约束下载或构建。已有的失败类型与 `failedAt: 'spec-host'` 让客户端显示连接失败或超时,并提供国内镜像供用户输入另一个包名。取消安装或销毁管理器会先终止检查及其子进程,再结束操作。
**取消属于管理器,不属于信号。** 对话框为每次运行生成一个 request id;宿主在它之下流式转发运行的输出与阶段,`cancelInstall` 只在 pnpm 退出且文件恢复后才答复。对话框等到这个答复才把 spec 重新交回;中止的 RPC 或断开的连接都不算确认。只有检查末尾接受 `AbortSignal`:返回编辑或关闭会丢掉一次注册表查询,没有人等它的结果。
**启用在事后。** 运行以 `enabled: false` 安装;完成画面为它新增的组合包提供**立即启用**,对话框关闭,列表滚动到它。在人看到装了什么之前,不会启用任何东西。
@@ -36,8 +38,10 @@ Status: implemented
## 后果
`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand` 与 `inspectTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
`inspect`、`cancelInstall` 以及 `plugin-manager/changed`、`plugin-manager/install-log`、`plugin-manager/install-state` 事件加入管理器的 Remote;`listBundles` 携带标题、行与覆盖项;`ChangeResult` 增加 `cancelled`、`bundle` 与 `packageResult.kind`;配置增加 `pnpmCommand`、`inspectTimeoutMs` 与 `githubConnectionTimeoutMs`。对话框是围绕同一张主题卡的四个画面。
## 测试
`packages/boot/plugin-manager/tests/install-spec.spec.ts` 钉住 spec 形式与失败分类器的输入;`manager.spec.ts` 用桩住的注册表查询和真实目录驱动 `inspect`,流式转发一次运行,停下一次运行并检查恢复后的文件,并检查变更事件;`operations.spec.ts` 覆盖注册表查询。`packages/client/ui-plugin-manager/tests` 覆盖 store 的阶段、经宿主确认的取消、装后启用、toast 与页面的四个画面;`apps/web/tests/plugin-manager.e2e.ts` 经真实宿主拒绝已装名字、不存在的路径和坏名字,并实时切换一个组合包及其中一行,`plugin-install-cancel.e2e.ts` 从对话框停下一个真实子进程、检查恢复后的文件,并在第二次尝试时装成,`plugin-install-approve.e2e.ts` 用假 pnpm 把脚本留作未决、从对话框允许后在重试中装上。
`github-connection.spec.ts` 用真实 Git 和测试自有的本机网络端点覆盖立即失败、超时、取消与非交互认证。`manager.spec.ts` 还通过真实 Git 与 pnpm 安装本地仓库,覆盖 HTTPS、GitHub 简写和 SSH 地址,用自有代理隔离 pnpm 的 HTTPS 请求并验证其 SSH 回退。`plugin-install-github.e2e.ts` 检查连接失败及超时弹窗、子进程清理,以及切换镜像时不重试原 GitHub 地址。
@@ -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-08-05-agent-teams.md
2026-08-05-agent-teams.md: 45c7e599e03bb26dc9224c74a6d3bc0ab8a74dcc
2026-08-05-agent-teams.zh.md: dbb2931bdc40da5ddf2c8091dd8c401029e0445f
2026-08-05-agent-teams.md: 6333e511ba1e71823cfa55c27ba46b2665d1ef15
2026-08-05-agent-teams.zh.md: 3fed1302146406f71d63c7ff2df8f031a91c9804
@@ -54,6 +54,14 @@ All members use the same cwd and observe writes immediately. The policy tells me
Worktree isolation is not a harness runtime behavior. A deployment or prompt may arrange separate worktrees, but the Team domain does not infer branches, merge changes, or silently change cwd. This preserves the existing same-world subagent and sandbox contracts.
## Web projection
The Web panel reads the Lead Session's `agentTeam` wire projection because the existing projection stream keeps browser state current without a separate Team read API. Both Lead and teammate conversations select values from the shared cross-Session store. The panel owns no projection reads: Session loading, cached list values, and control baselines supply the data.
The wire view carries durable roster phases, non-deleted task views with shared readiness and overlap derivation, and `failure` beside the last valid state. Live activity comes from Session status. Model selection has its own durable projection; the panel displays the shared store's available values without opening member conversations. A missing cached value remains absent until Session loading or a live update supplies it.
Publication requires `apply` to return a new state object per applied Team event and replace only the touched collection. Mailbox-only events retain the view reference and publish nothing. The existing carrier sends each changed complete view to every connected browser; the package README records that cost.
## Alternatives considered
**Extend direct-child subagent tools with peer ids.** Rejected because parent/child authority and Team peer membership are different domains. Adding peer access to the continuation seam would weaken its exact-parent authorization and still leave roster and tasks without a persistence owner.
@@ -54,6 +54,14 @@ Peer 通讯使用 Lead 日志 mailbox。投递前先追加并 flush `team/messag
Worktree isolation 不是 harness runtime 行为。deployment 或 prompt 可以安排独立 worktree,但 Team 领域不会推断 branch、merge 变更或静默改变 cwd。这样保留既有 same-world subagent 与 sandbox 契约。
## Web projection
Web panel 读取 Lead Session 的 `agentTeam` 传输投影,因为现有投影流能持续更新浏览器状态,无需独立的 Team 读取 API。Lead 与 teammate 会话均从共享的跨 Session store 选择值。面板不负责投影读取:Session 加载、列表缓存值与控制基线提供数据。
传输视图携带持久 roster phase、使用共享就绪与重叠派生逻辑的未删除任务视图,以及最后有效状态旁的 `failure`。实时活动来自 Session 状态。模型选择有自己的持久投影;面板展示共享 store 中可用的值,不打开成员会话。缺少的缓存值保持缺失,直到 Session 加载或实时更新提供它。
发布要求 `apply` 对每个已应用的 Team 事件返回新的 state 对象,并只替换被触及的集合。仅邮箱的事件保留视图引用,不发布 frame。现有传输层将每个变化后的完整视图发送给所有已连接浏览器;包 README 记录了这项开销。
## Alternatives considered
**用 peer id 扩展 direct-child subagent tool。** 拒绝,因为 parent/child 权限与 Team peer membership 是不同领域。向 continuation seam 增加 peer access 会削弱 exact-parent authorization,仍无法为 roster 与 task 提供持久 owner。
+1 -2
View File
@@ -389,8 +389,7 @@ describe('web e2e: agent-preset selection', () => {
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('Minimal mode')
expect(snapshot).toContain('button "1 subagent"')
// The preset label leads the header band; delegation navigation follows it.
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode'))
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"'))
// Static chrome, not a control: the header can only report a composition
// the host would refuse to change.
+60 -23
View File
@@ -1,11 +1,11 @@
// Keyless assembled-browser coverage for the opt-in Agent Teams bundle
// over the real Host Typert Remote flow.
// over the real Host projection frame flow.
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { readFileSync } from 'node:fs'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { afterAll, beforeAll, describe, expect, it, onTestFailed, onTestFinished, vi } from 'vitest'
import * as yaml from 'js-yaml'
import { entryListSchema } from '@deepseek-ai/cordis-plugin-include'
import type {} from '@deepseek-ai/dsh-experimental-agent-team'
@@ -18,6 +18,7 @@ import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './suppor
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/agent-team-panel', import.meta.url))
const PANEL_EXPECTED = join(SNAPSHOT_DIR, 'task.expected.md')
const RESIZE_EXPECTED = join(SNAPSHOT_DIR, 'resize.expected.md')
const OVERLAY = fileURLToPath(new URL('./agent-team-panel.overlay.yml', import.meta.url))
const TEAM_PATCH = fileURLToPath(new URL('../../../packages/experimental/agent-team-profile/cordis.patch.yml', import.meta.url))
const INSTALL_ANCHORS = [
@@ -80,26 +81,29 @@ describe('web e2e: Agent Teams panel', () => {
await scaffold?.close()
})
it('displays agent-owned tasks through a read-only board', async () => {
it('displays agent-owned task changes through a read-only board without a refresh action', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-agent-team-panel'))
const projectionReads = vi.spyOn(scaffold.ctx.sessionController, 'projections')
onTestFinished(() => { projectionReads.mockRestore() })
await page.locator('[data-team-action]').getByRole('button', { name: /Agent Team/iu }).click()
const action = page.getByRole('dialog', { name: 'Agent Team', exact: true })
await action.getByText('No shared tasks yet').waitFor()
await action.getByText('No shared tasks yet. Create them through the conversation.').waitFor()
await action.getByText('lead').waitFor()
expect(projectionReads).not.toHaveBeenCalled()
expect(await action.getByRole('button', { name: 'New task' }).count()).toBe(0)
expect(await action.getByRole('button', { name: /Refresh/u }).count()).toBe(0)
// Host-side Team mutations reach the open panel through projection frames only.
const agent = scaffold.ctx.agents.list()[0]!
const task = await scaffold.ctx.agentTeams.createTask(agent, {
subject: 'Agent task', description: 'Created by the Team Lead', writeScopes: ['src/web'],
})
await action.getByRole('button', { name: 'Refresh Team' }).click()
await action.getByText('Agent task', { exact: true }).waitFor()
await action.getByText('Owner: Unowned', { exact: true }).waitFor()
await scaffold.ctx.agentTeams.updateTask(agent, {
taskId: task.id, expectedRevision: task.revision, action: 'claim',
})
await action.getByRole('button', { name: 'Refresh Team' }).click()
await action.getByText('In progress', { exact: true }).waitFor()
await action.getByText('Owner: lead', { exact: true }).waitFor()
expect(await action.getByRole('button', { name: /^(New task|Edit|Complete|Reopen|Delete)$/u }).count()).toBe(0)
@@ -107,9 +111,10 @@ describe('web e2e: Agent Teams panel', () => {
const snapshot = await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd)
await compareOrRefreshGolden(PANEL_EXPECTED, snapshot, MODE)
expect(projectionReads).not.toHaveBeenCalled()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await action.getByRole('button', { name: 'Close' }).click()
await page.keyboard.press('Escape')
}, 60_000)
it('keeps the panel inside the viewport and closes on outside click or Escape', async () => {
@@ -131,35 +136,24 @@ describe('web e2e: Agent Teams panel', () => {
.every(([x, y]) => element.contains(document.elementFromPoint(x!, y!)))
})).toBe(true)
}
await panel.getByText('Shared tasks', { exact: true }).click()
await panel.getByRole('heading', { name: /Shared tasks/u }).click()
expect(await panel.count()).toBe(1)
await page.mouse.click(2, 2)
await panel.waitFor({ state: 'detached' })
await page.setViewportSize(viewport)
await trigger.focus()
await page.keyboard.press('Enter')
await panel.getByRole('heading', { name: 'Shared tasks' }).waitFor()
await panel.getByRole('heading', { name: /Shared tasks/u }).waitFor()
expect(await panel.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Tab')
expect(await panel.getByRole('button', { name: 'Refresh Team' })
.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Tab')
expect(await panel.getByRole('button', { name: 'Close', exact: true })
.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Escape')
await panel.waitFor({ state: 'detached' })
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Enter')
await panel.waitFor()
await page.keyboard.press('Tab')
await page.keyboard.press('Tab')
await page.keyboard.press('Enter')
await panel.waitFor({ state: 'detached' })
expect(await trigger.evaluate(element => element === document.activeElement)).toBe(true)
await page.keyboard.press('Enter')
await panel.waitFor()
const outside = page.getByRole('button', { name: 'Settings', exact: true })
await outside.focus()
expect(await panel.count()).toBe(1)
await page.keyboard.press('Escape')
await panel.waitFor({ state: 'detached' })
expect(await outside.evaluate(element => element === document.activeElement)).toBe(true)
} finally {
@@ -167,7 +161,50 @@ describe('web e2e: Agent Teams panel', () => {
}
})
it('keeps the hovered panel open when the pointer returns directly to its trigger', async () => {
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
await trigger.hover()
await panel.waitFor()
await panel.hover()
await trigger.hover()
// The assertion spans the 120ms dismissal grace while the pointer stays on the trigger.
await page.waitForTimeout(180)
expect(await panel.count()).toBe(1)
await page.keyboard.press('Escape')
})
it('offers expansion when resizing makes a task description exceed two lines', async () => {
const agent = scaffold.ctx.agents.list()[0]!
await scaffold.ctx.agentTeams.createTask(agent, {
subject: 'Responsive task',
description: 'Check the task description before you start work. '.repeat(3),
writeScopes: [],
})
const viewport = page.viewportSize()!
const trigger = page.locator('[data-team-action]').getByRole('button', { name: 'Agent Team', exact: true })
const panel = page.getByRole('dialog', { name: 'Agent Team', exact: true })
const card = panel.locator('article').filter({ hasText: 'Responsive task' })
await trigger.click()
await card.waitFor()
expect(await card.getByRole('button', { name: 'Show more' }).count()).toBe(0)
try {
await page.setViewportSize({ width: 360, height: 700 })
await card.getByRole('button', { name: 'Show more' }).waitFor()
expect(await card.locator('p').evaluate(p => p.scrollHeight > p.clientHeight)).toBe(true)
await compareOrRefreshGolden(RESIZE_EXPECTED, await captureStableAria(page, '[data-team-panel]', scaffold.workspaceCwd), MODE)
await card.getByRole('button', { name: 'Show more' }).click()
expect(await card.locator('p').evaluate(p => p.scrollHeight === p.clientHeight)).toBe(true)
await card.getByRole('button', { name: 'Show less' }).click()
await page.setViewportSize(viewport)
await expect.poll(() => card.getByRole('button', { name: 'Show more' }).count()).toBe(0)
} finally {
await page.keyboard.press('Escape')
await page.setViewportSize(viewport)
}
})
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['task.expected.md', 'resize.expected.md'])
})
})
@@ -9,7 +9,7 @@
- button "关闭"
- heading "Agent 预设" [level=2]
- paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。
- text: 新任务可选择模式 Beta
- text: 新任务可选择模式 实验性
- paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。
- switch "新任务可选择模式" [checked]
- heading "内置" [level=3]
@@ -1,5 +1,5 @@
- navigation "Session hierarchy": Seeded turn
- text: Minimal mode
- button "1 subagent"
- text: Minimal mode
- button "More actions"
- button "Open right sidebar"
@@ -7,11 +7,11 @@
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
@@ -0,0 +1,6 @@
- dialog "连接 GitHub 超时":
- heading "连接 GitHub 超时" [level=2]
- button "关闭"
- paragraph: 请尝试其他安装来源。
- button "取消"
- button "改用国内镜像"
@@ -7,11 +7,11 @@
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
@@ -7,11 +7,11 @@
- list:
- listitem:
- button "查看 智能体团队": 智能体团队
- text: Beta 启用团队协作、团队工具、成员列表和共享任务看板。
- text: 实验性 启用团队协作、团队工具、成员列表和共享任务看板。
- switch "启用 智能体团队"
- listitem:
- button "查看 语音输入": 语音输入
- text: Beta 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- text: 实验性 在本机使用 SenseVoice 转写录音;首次使用需安装依赖
- switch "启用 语音输入"
- listitem:
- button "查看 终端": 终端
+4 -1
View File
@@ -13,7 +13,7 @@ import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/goal-bar', import.meta.url))
const ACTIVE_EXPECTED = join(SNAPSHOT_DIR, 'active.expected.md')
@@ -58,6 +58,9 @@ describe('web e2e: goal bar clear convergence', () => {
await pause.hover()
const pauseTooltip = page.getByRole('tooltip', { name: 'Pause goal', exact: true })
await pauseTooltip.waitFor()
// The strip rides directly above the input card, which paints later; the
// bubble must escape the strip's stacking context instead of landing under it.
await expectTooltipOnTop(pauseTooltip)
const tooltipGeometry = await page.evaluate(() => {
const element = document.querySelector<HTMLElement>('[role="tooltip"]')
if (element === null) return null
+41 -11
View File
@@ -1,6 +1,8 @@
// The real installer classifies a controlled GitHub network failure; the browser
// Real Git reaches a controlled proxy failure or stall; the browser
// offers a mirror for a replacement spec without retrying the failed address.
import { once } from 'node:events'
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { createServer, type Socket } from 'node:net'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -9,12 +11,33 @@ import { expect, it, onTestFinished } from 'vitest'
import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole } from './scaffold.ts'
import { ZH_BROWSER_LOCALE } from './support.ts'
it('offers a mirror after GitHub fails and waits for replacement input', async () => {
it.each(['network', 'timeout'] as const)('offers a mirror after a GitHub %s and waits for replacement input', async (failure) => {
const scratch = await mkdtemp(join(tmpdir(), 'dsh-install-github-'))
onTestFinished(() => rm(scratch, { recursive: true, force: true }))
const sockets = new Set<Socket>()
let connections = 0
const proxy = createServer((socket) => {
sockets.add(socket)
socket.on('error', () => {})
socket.once('close', () => { sockets.delete(socket) })
socket.once('data', () => { connections++; if (failure === 'network') socket.destroy() })
})
onTestFinished(async () => {
for (const socket of sockets) socket.destroy()
await new Promise<void>((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) })
})
proxy.listen(0, '127.0.0.1')
await once(proxy, 'listening')
const address = proxy.address()
if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port')
const gitConfig = join(scratch, 'git.config')
await writeFile(gitConfig, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`)
const overlay = join(scratch, 'cordis.patch.yml')
await writeFile(overlay, `- id: plugin-manager\n config: ${JSON.stringify({ pnpmCommand: process.execPath })}\n`)
const scaffold = await launchWebScaffold({ profile: { packages: [] }, extraOverlayPath: overlay })
await writeFile(overlay, '- id: ui-plugin-manager\n config: { registryProbeEnabled: false }\n')
const scaffold = await launchWebScaffold({
profile: { packages: [], packageManager: { command: process.execPath, args: [], env: { GIT_CONFIG_GLOBAL: gitConfig, GIT_CONFIG_NOSYSTEM: '1' } } },
extraOverlayPath: overlay,
})
onTestFinished(() => scaffold.close())
const browser = await chromium.launch()
onTestFinished(() => browser.close())
@@ -22,10 +45,11 @@ it('offers a mirror after GitHub fails and waits for replacement input', async (
const manifestPath = join(profile, 'package.json')
const manifestBefore = await readFile(manifestPath, 'utf8')
await writeFile(join(profile, 'config'), 'console.log("https://registry.npmjs.org/")\n')
await writeFile(join(profile, '.attempts'), '')
await writeFile(join(profile, 'add'), `
const fs = require('node:fs');
fs.appendFileSync('.attempts', JSON.stringify(process.argv) + '\\n');
console.error("fatal: unable to access 'https://github.com/example/dsh-plugin.git/': Could not resolve host: github.com");
console.error('pnpm must not start after the GitHub connection check fails');
process.exitCode = 1;
`)
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: ZH_BROWSER_LOCALE })
@@ -36,21 +60,25 @@ it('offers a mirror after GitHub fails and waits for replacement input', async (
await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click()
await page.getByRole('button', { name: '添加插件', exact: true }).click()
const spec = 'https://github.com/example/dsh-plugin.git'
const title = failure === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub'
let dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
await dialog.getByRole('button', { name: '安装源 默认安装源', exact: true }).waitFor()
await dialog.getByRole('textbox', { name: '包名或地址' }).fill(spec)
expect(await page.getByText('无法访问 GitHub', { exact: true }).count()).toBe(0)
expect(await page.getByText(title, { exact: true }).count()).toBe(0)
await dialog.getByRole('button', { name: '安装', exact: true }).click()
dialog = page.getByRole('dialog', { name: '无法访问 GitHub', exact: true })
dialog = page.getByRole('dialog', { name: title, exact: true })
await dialog.waitFor()
expect(await page.getByRole('dialog').count()).toBe(1)
expect(await dialog.getByText('请尝试其他安装来源。', { exact: true }).count()).toBe(1)
await compareOrRefreshGolden(
fileURLToPath(new URL('./expected/plugin-install-github/failed.expected.md', import.meta.url)),
fileURLToPath(new URL(`./expected/plugin-install-github/${failure === 'timeout' ? 'timeout' : 'failed'}.expected.md`, import.meta.url)),
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(),
)
const attempts = await readFile(join(profile, '.attempts'), 'utf8')
expect(attempts.trim().split('\n')).toHaveLength(1)
expect(attempts).toBe('')
expect(connections).toBeGreaterThan(0)
await expect.poll(() => sockets.size).toBe(0)
const checked = connections
await dialog.getByRole('button', { name: '改用国内镜像', exact: true }).click()
dialog = page.getByRole('dialog', { name: '添加插件', exact: true })
const input = dialog.getByRole('textbox', { name: '插件包名', exact: true })
@@ -64,12 +92,14 @@ it('offers a mirror after GitHub fails and waits for replacement input', async (
await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), webSnapshotMode(),
)
expect(await readFile(join(profile, '.attempts'), 'utf8')).toBe(attempts)
expect(connections).toBe(checked)
expect(await readFile(manifestPath, 'utf8')).toBe(manifestBefore)
await input.fill(spec)
await dialog.getByRole('button', { name: '安装', exact: true }).click()
dialog = page.getByRole('dialog', { name: '无法访问 GitHub', exact: true })
dialog = page.getByRole('dialog', { name: title, exact: true })
await dialog.getByRole('button', { name: '取消', exact: true }).click()
await dialog.waitFor({ state: 'detached' })
expect((await readFile(join(profile, '.attempts'), 'utf8')).trim().split('\n')).toHaveLength(2)
expect(await readFile(join(profile, '.attempts'), 'utf8')).toBe('')
expect(connections).toBeGreaterThan(checked)
expect(tripwire.pageErrors).toEqual([])
})
+7 -4
View File
@@ -128,7 +128,7 @@ describe('web e2e: plugin manager', () => {
expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-package]').count()).toBe(2)
expect(await panel.locator('[data-plugin-group="official"] [data-plugin-item]').count()).toBe(4)
expect(await panel.getByText('Beta', { exact: true }).count()).toBe(2)
expect(await panel.getByText('实验性', { exact: true }).count()).toBe(2)
expect(await panel.getByRole('switch', { name: '启用 语音输入', exact: true }).getAttribute('aria-checked')).toBe('false')
// A bundle that is off still shows the rows its patch declares, without switches.
await panel.getByRole('button', { name: '查看 @fixture/bundle' }).click()
@@ -278,9 +278,12 @@ describe('web e2e: plugin manager', () => {
await expect.poll(() => teamRows().filter(entry => entry.fiber?.state === FiberState.ACTIVE).length, { timeout: 20_000 }).toBe(3)
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('true')
await action.waitFor({ timeout: 20_000 })
await action.getByRole('button', { name: /Agent Team/iu }).click()
const teamPanel = teamPage.getByRole('dialog', { name: 'Agent Team', exact: true })
await teamPanel.getByText('还没有共享任务').waitFor()
await action.getByRole('button', { name: '智能体团队', exact: true }).click()
const teamPanel = teamPage.getByRole('dialog', { name: '智能体团队', exact: true })
await teamPanel.getByText('Team 暂不可用', { exact: true }).waitFor()
await teamPage.reload({ waitUntil: 'load' })
await action.getByRole('button', { name: '智能体团队', exact: true }).click()
await teamPanel.getByText('暂无共享任务,可以通过对话创建').waitFor()
await teamPanel.getByText('lead', { exact: true }).waitFor()
const manifest = JSON.parse(await homeFile('profiles', 'scaffold', 'package.json')) as {
dsh: { profile: { bundles: string[] } }
+7 -2
View File
@@ -16,7 +16,7 @@ import {
assertFixtureInventory, captureExpandedTurnProcessAria, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
import { connectFreshWorkspace, expectTooltipOnTop, newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/queue-actions', import.meta.url))
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/live-interactions/session.v3.jsonl', import.meta.url))
@@ -196,6 +196,9 @@ describe('web e2e: queue row actions', () => {
await save.hover()
const saveTooltip = page.getByRole('tooltip', { name: 'Save queued message', exact: true })
await saveTooltip.waitFor()
// The dock tucks under the input card, which paints later; the bubble must
// escape the panel's stacking context instead of landing behind it.
await expectTooltipOnTop(saveTooltip)
const tooltipGeometry = await page.evaluate(() => {
const element = document.querySelector<HTMLElement>('[role="tooltip"]')
if (element === null) return null
@@ -222,7 +225,9 @@ describe('web e2e: queue row actions', () => {
const remainingEdit = page.getByRole('button', { name: 'Edit queued message', exact: true })
await expect.poll(() => remainingEdit.isEnabled(), { timeout: 10_000 }).toBe(true)
await remainingEdit.hover()
await page.getByRole('tooltip', { name: 'Edit queued message', exact: true }).waitFor()
const editTooltip = page.getByRole('tooltip', { name: 'Edit queued message', exact: true })
await editTooltip.waitFor()
await expectTooltipOnTop(editTooltip)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
+3
View File
@@ -334,6 +334,8 @@ export interface LaunchOptions {
*/
profile?: {
hmr?: boolean
/** Launcher-owned invocation and environment for profile package operations. */
packageManager?: ProfileContext['packageManager']
packages: { dir: string; enabled?: boolean }[]
/** Additional selected names, including bundles unavailable after an upgrade. */
bundles?: readonly string[]
@@ -772,6 +774,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
await writeFile(join(profileDir, 'package.json'), JSON.stringify(manifest, null, 2) + '\n')
profileContext = {
name: 'scaffold', dir: profileDir, patchPath: profile.patchPath, installAnchor: INSTALL_ANCHOR,
...options.profile?.packageManager === undefined ? {} : { packageManager: options.profile.packageManager },
cwd: workspaceCwd, home: harnessHome,
startedBundles: loadProfileDirectory('dsh', profileDir, INSTALL_ANCHOR).layers.map(layer => layer.packageName),
overlays: processOverlays, telemetryDisabledEnv: undefined,
+8 -3
View File
@@ -475,11 +475,16 @@ describe.skipIf(process.platform === 'win32')('Web sidebar terminal', () => {
await page.context().setOffline(false)
await page.reload({ waitUntil: 'load' })
await page.locator('[data-sidebar-right-expand]').click()
await expect.poll(() => page.getByRole('alert').innerText()).toContain('no longer exists')
const terminal = page.locator('[data-sidebar-terminal]')
let unavailableSnapshot = ''
// Mounting can refresh a failed recovered view; readiness and comparison use the same DOM sample.
await expect.poll(async () => {
unavailableSnapshot = await terminal.ariaSnapshot()
return unavailableSnapshot
}).toContain('no longer exists')
expect(handles).toHaveLength(1)
const unavailable = fileURLToPath(new URL('./expected/sidebar-terminal/unavailable.expected.md', import.meta.url))
const terminal = page.locator('[data-sidebar-terminal]')
await compareOrRefreshGolden(unavailable, await terminal.ariaSnapshot(), webSnapshotMode())
await compareOrRefreshGolden(unavailable, unavailableSnapshot, webSnapshotMode())
await terminal.screenshot({ path: `${shots}/unavailable.png`, animations: 'disabled' })
const tabCount = await page.locator('[data-dockkit-tab]').count()
const create = terminal.getByRole('button', { name: 'New terminal', exact: true })
@@ -0,0 +1,15 @@
- dialog "Agent Team":
- heading "Members" [level=3]
- button "lead Current chat Inactive" [disabled]
- heading "Shared tasks 2" [level=3]
- article:
- strong: Agent task
- text: In progress
- paragraph: Created by the Team Lead
- text: "task-1Owner: leadWrite scopes: src/web"
- article:
- strong: Responsive task
- text: Pending
- paragraph: Check the task description before you start work. Check the task description before you start work. Check the task description before you start work.
- button "Show more"
- text: "task-2Owner: UnownedReady"
@@ -1,12 +1,9 @@
- dialog "Agent Team":
- strong: Agent Team
- button "Refresh Team"
- button "Close"
- heading "Members" [level=3]
- 'button "lead Inactive · Model: deepseek-v4-flash" [disabled]'
- heading "Shared tasks" [level=3]
- button "lead Current chat Inactive" [disabled]
- heading "Shared tasks 1" [level=3]
- article:
- strong: Agent task
- text: In progress
- paragraph: Created by the Team Lead
- text: "task-1 Owner: lead Write scopes: src/web"
- text: "task-1Owner: leadWrite scopes: src/web"
+8 -8
View File
@@ -404,7 +404,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
it('opens child history in the right Sidebar and releases it when closed', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-subagent-sidebar-chat'))
await page.getByRole('button', { name: '2 subagents' }).hover()
await page.getByRole('button', { name: `Open ${LABEL} in sidebar` }).click()
await page.getByRole('treeitem', { name: new RegExp(LABEL) }).getByRole('button', { name: `Open ${LABEL} in sidebar`, exact: true }).click()
const sidebarChat = page.locator('[data-sidebar-chat]')
await sidebarChat.getByText(/^Explain event sourcing in one sentence\.Your parent agent id is /).waitFor({ timeout: 15_000 })
await compareOrRefreshGolden(
@@ -416,7 +416,7 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
await sidebarChat.waitFor({ state: 'detached' })
await page.getByRole('button', { name: '2 subagents' }).hover()
await page.getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar` }).click()
await page.getByRole('treeitem', { name: new RegExp(ONE_SHOT_LABEL) }).getByRole('button', { name: `Open ${ONE_SHOT_LABEL} in sidebar`, exact: true }).click()
await page.locator('[data-sidebar-chat]').getByText(
'One-shot tasks do not accept follow-ups; review the full execution record here.',
).waitFor({ timeout: 15_000 })
@@ -532,16 +532,16 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
expect(await nestedDisclosure.count()).toBe(0)
const clickArea = nestedRow.locator(':scope > div')
expect(await clickArea.count()).toBe(1)
const [treeBox, clickAreaBox] = await Promise.all([
tree.boundingBox(),
const [menuBox, clickAreaBox] = await Promise.all([
tree.locator('..').boundingBox(),
clickArea.boundingBox(),
])
expect(treeBox).not.toBeNull()
expect(menuBox).not.toBeNull()
expect(clickAreaBox).not.toBeNull()
expect([
Math.round(clickAreaBox!.x - treeBox!.x),
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
// Compact menu padding alone insets the rows now that the border is gone.
Math.round(clickAreaBox!.x - menuBox!.x),
Math.round(menuBox!.x + menuBox!.width - clickAreaBox!.x - clickAreaBox!.width),
// The outer menu padding insets the scrollable tree rows.
]).toEqual([3, 3])
await compareOrRefreshGolden(
BRANCHLESS_EXPECTED,
+24
View File
@@ -225,6 +225,30 @@ export async function saveFailureShot(page: Page, name: string): Promise<void> {
}
}
/**
* Assert a visible tooltip paints above the element a user would read through
* it, at the bubble's center and bottom edge. The bubble ignores pointer events
* by design, so the measurement enables them for its own duration; each probe
* reports the bubble or the covering element, so a failure names its cover.
* @param tooltip - locator for the visible `[role="tooltip"]` bubble.
*/
export async function expectTooltipOnTop(tooltip: Locator): Promise<void> {
const probes = await tooltip.evaluate((bubble) => {
const rect = bubble.getBoundingClientRect()
const previous = bubble.style.pointerEvents
bubble.style.pointerEvents = 'auto'
const probe = (y: number): string => {
const hit = document.elementFromPoint(rect.left + rect.width / 2, y)
if (hit === null) return 'none'
return bubble.contains(hit) ? 'tooltip' : `${hit.tagName}.${hit.classList.value}`.slice(0, 120)
}
const probes = { center: probe(rect.top + rect.height / 2), bottom: probe(rect.bottom - 1) }
bubble.style.pointerEvents = previous
return probes
})
expect(probes).toEqual({ center: 'tooltip', bottom: 'tooltip' })
}
/**
* The conversation engine's Context key format, restated here rather than
* imported: these specs live in the Host compiler aggregate, which must not
+2 -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 docs/capability-seams.md
capability-seams.md: 6550250f0cc89ea5ca1c67c07894bc04385f93f6
capability-seams.zh.md: f71f021a32f07eec129e892f8d65445ad7b8d264
capability-seams.md: 2914231ceaaa243b0c918a9477e554aa1641ad91
capability-seams.zh.md: a1223a351c26d668705df327102ac5e7e521f9ec
+1 -3
View File
@@ -232,7 +232,6 @@ flowchart LR
pkg_experimental_agent_team["experimental-agent-team"]
svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
pkg_inspector["inspector"]
svc_inspector["ctx.inspector<br/>Cross-realm runtime inspection"]
pkg_jobs["jobs"]
@@ -419,7 +418,6 @@ flowchart LR
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
svc_agentLoop --> pkg_sdk_minimal
svc_agentTeams --> pkg_experimental_client_ui_agent_team
svc_agentTeams --> pkg_experimental_tool_agent_team
svc_agents --> pkg_acp
svc_agents --> pkg_agent_loop
@@ -643,7 +641,7 @@ flowchart LR
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | The basic backend consumes post-step pressure and request-error recovery events; there is no model-facing compact tool. |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
| `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. |
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, continuable-child lifecycle, and generated Team Remote methods; tool-agent-team contributes model controls and client-ui-agent-team mounts the browser contribution. |
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. |
| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. |
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
+1 -3
View File
@@ -234,7 +234,6 @@ flowchart LR
pkg_experimental_agent_team["experimental-agent-team"]
svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
pkg_inspector["inspector"]
svc_inspector["ctx.inspector<br/>Cross-realm runtime inspection"]
pkg_jobs["jobs"]
@@ -421,7 +420,6 @@ flowchart LR
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_base
svc_agentLoop --> pkg_sdk_minimal
svc_agentTeams --> pkg_experimental_client_ui_agent_team
svc_agentTeams --> pkg_experimental_tool_agent_team
svc_agents --> pkg_acp
svc_agents --> pkg_agent_loop
@@ -645,7 +643,7 @@ flowchart LR
| `ctx.compaction` | `seam` | [`compaction`](../packages/compaction/compaction) | [`compaction-basic`](../packages/compaction/compaction-basic) | [`compaction-basic`](../packages/compaction/compaction-basic) | - | 基础后端消费步骤后的压力事件和请求错误恢复事件;不存在面向模型的压缩工具。 |
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 |
| `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 |
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team), [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG、continuable child 生命周期与生成式 Team Remote method;tool-agent-team 提供模型控制工具,client-ui-agent-team 挂载浏览器 contribution。 |
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 |
| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 |
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
+2 -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 docs/config-catalog.md
config-catalog.md: 37024a43c06e40015b8cee061a957d86ff27416c
config-catalog.zh.md: b73c2cba6b91dc1dd0f181f32f51c42832720cc3
config-catalog.md: 5749158386933f39c329559016499179735eaea2
config-catalog.zh.md: d43b9f98529c926f61b7ea1ecbe3a37bf0cb4715
+6 -4
View File
@@ -746,7 +746,7 @@ export interface Config {
}
```
Source: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts)
<a id="deepseek-aidsh-experimental-api-speech-to-text"></a>
@@ -2169,16 +2169,18 @@ Source: [`packages/plan/plan-mode/src/index.ts:70`](../packages/plan/plan-mode/s
Requires: `loader` · `profileContext`
```ts config-catalog
/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
export interface Config {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
pnpmCommand?: string
/** Maximum retained pnpm diagnostic bytes per operation. */
/** Maximum retained package-operation diagnostic bytes. */
outputBytes?: number
/** Maximum time to wait for another process's profile package operation. */
lockWaitMs?: number
/** Bound on one registry lookup an inspection runs, in milliseconds. */
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
registry?: string
/**
@@ -2190,7 +2192,7 @@ export interface Config {
}
```
Source: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts)
Source: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts)
<a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
+6 -4
View File
@@ -748,7 +748,7 @@ export interface Config {
}
```
来源: [`packages/experimental/agent-team/src/types.ts:130`](../packages/experimental/agent-team/src/types.ts)
来源: [`packages/experimental/agent-team/src/types.ts:152`](../packages/experimental/agent-team/src/types.ts)
<a id="deepseek-aidsh-experimental-api-speech-to-text"></a>
@@ -2171,16 +2171,18 @@ export interface PlanModeConfig {
需要: `loader` · `profileContext`
```ts config-catalog
/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
export interface Config {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
pnpmCommand?: string
/** Maximum retained pnpm diagnostic bytes per operation. */
/** Maximum retained package-operation diagnostic bytes. */
outputBytes?: number
/** Maximum time to wait for another process's profile package operation. */
lockWaitMs?: number
/** Bound on one registry lookup an inspection runs, in milliseconds. */
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
registry?: string
/**
@@ -2192,7 +2194,7 @@ export interface Config {
}
```
来源: [`packages/boot/plugin-manager/src/index.ts:37`](../packages/boot/plugin-manager/src/index.ts)
来源: [`packages/boot/plugin-manager/src/index.ts:38`](../packages/boot/plugin-manager/src/index.ts)
<a id="deepseek-aidsh-plugin-package-inventory-deepseek"></a>
+2 -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 docs/module-graph.md
module-graph.md: 3d7ad447253fa8748bb5f9fa5a19c656bc13cc72
module-graph.zh.md: 0c3c74fcb49711b070106d74467f51de2608453e
module-graph.md: 5dee52429dc8261dcb5d33db1962360bed1b52a5
module-graph.zh.md: e4f62aba0b39ee1ba9cdd12babfee1eeb4f07dcd
+2 -5
View File
@@ -1250,7 +1250,6 @@ flowchart TD
pkg_experimental_agent_team --> pkg_session_persistence
pkg_experimental_agent_team --> pkg_session_projection
pkg_experimental_agent_team --> pkg_subagent
pkg_experimental_agent_team --> pkg_typert_protocol
pkg_sdk_protocol --> pkg_llm
pkg_sdk_protocol --> pkg_session
pkg_sdk_protocol --> pkg_subagent
@@ -1275,7 +1274,6 @@ flowchart TD
pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver
pkg_subagent_spawn_in_process --> pkg_subagent
pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver
pkg_experimental_client_ui_agent_team --> pkg_api_remotes
pkg_experimental_client_ui_agent_team --> pkg_api_session_controller
pkg_experimental_client_ui_agent_team --> pkg_client_locale
pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation
@@ -1286,7 +1284,6 @@ flowchart TD
pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace
pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team
pkg_experimental_client_ui_agent_team --> pkg_session
pkg_experimental_client_ui_agent_team --> pkg_typert_protocol
pkg_experimental_client_ui_voice_input --> pkg_api_gateway
pkg_experimental_client_ui_voice_input --> pkg_api_remotes
pkg_experimental_client_ui_voice_input --> pkg_api_session_controller
@@ -1642,13 +1639,13 @@ flowchart TD
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) |
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) |
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
+2 -5
View File
@@ -1252,7 +1252,6 @@ flowchart TD
pkg_experimental_agent_team --> pkg_session_persistence
pkg_experimental_agent_team --> pkg_session_projection
pkg_experimental_agent_team --> pkg_subagent
pkg_experimental_agent_team --> pkg_typert_protocol
pkg_sdk_protocol --> pkg_llm
pkg_sdk_protocol --> pkg_session
pkg_sdk_protocol --> pkg_subagent
@@ -1277,7 +1276,6 @@ flowchart TD
pkg_subagent_fork_in_process --> pkg_subagent_in_process_driver
pkg_subagent_spawn_in_process --> pkg_subagent
pkg_subagent_spawn_in_process --> pkg_subagent_in_process_driver
pkg_experimental_client_ui_agent_team --> pkg_api_remotes
pkg_experimental_client_ui_agent_team --> pkg_api_session_controller
pkg_experimental_client_ui_agent_team --> pkg_client_locale
pkg_experimental_client_ui_agent_team --> pkg_client_ui_conversation
@@ -1288,7 +1286,6 @@ flowchart TD
pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace
pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team
pkg_experimental_client_ui_agent_team --> pkg_session
pkg_experimental_client_ui_agent_team --> pkg_typert_protocol
pkg_experimental_client_ui_voice_input --> pkg_api_gateway
pkg_experimental_client_ui_voice_input --> pkg_api_remotes
pkg_experimental_client_ui_voice_input --> pkg_api_session_controller
@@ -1644,13 +1641,13 @@ flowchart TD
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent) |
| [`sdk-protocol`](../packages/sdk/protocol) | `sdk` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) |
| [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`workflow-ptc`](../packages/workflow/workflow-ptc) | `workflow` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`ptc-runtime`](../packages/ptc-runtime/ptc-runtime), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) |
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`sdk-client`](../packages/sdk/client) | `sdk` | [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/protocol), [`session`](../packages/core/session) |
+2 -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 docs/persistence-catalog.md
persistence-catalog.md: 022944bea076f49e054e7194e6b475cf6dfca2bc
persistence-catalog.zh.md: 096bf746085d329427dfb5516db4c6387f0974fe
persistence-catalog.md: 200e7156f65c4cba6fab6808a84326e85df69ddd
persistence-catalog.zh.md: fa48684d4456c6338b67d58bc051c3672d6ee2c9
+10 -10
View File
@@ -975,7 +975,7 @@ Source: [`packages/core/session/src/types.ts:330`](../packages/core/session/src/
Types: [TeamId](subsystems/agent-team.md) · [TeamMemberSnapshot](subsystems/agent-team.md)
Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
<a id="teammessagedelivered--log-only"></a>
@@ -993,7 +993,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experi
Types: [TeamId](subsystems/agent-team.md) · [TeamMessageId](subsystems/agent-team.md)
Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts)
<a id="teammessagequeued--log-only"></a>
@@ -1006,7 +1006,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experi
Types: [TeamId](subsystems/agent-team.md) · [TeamMessageSnapshot](subsystems/agent-team.md)
Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
<a id="teamtask--log-only"></a>
@@ -1019,7 +1019,7 @@ Source: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experi
Types: [TeamId](subsystems/agent-team.md) · [TeamTaskSnapshot](subsystems/agent-team.md)
Source: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
Source: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
### `todo/*`
@@ -4713,7 +4713,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:47`](../packages/experi
SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e`
Sources: [`packages/experimental/agent-team/src/types.ts:106`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:128`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -4733,7 +4733,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:106`](../packages/exper
SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0`
Sources: [`packages/experimental/agent-team/src/types.ts:115`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7373,7 +7373,7 @@ Sources: [`packages/web/web-search-deepseek/src/provider.ts:61`](../packages/web
SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1`
Sources: [`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7401,7 +7401,7 @@ Sources: [`packages/core/session/src/types.ts:330`](../packages/core/session/src
SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7415,7 +7415,7 @@ Sources: [`packages/experimental/agent-team/src/types.ts:214`](../packages/exper
SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1`
Sources: [`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
@@ -7561,7 +7561,7 @@ Sources: [`packages/compaction/compaction-image-offload/src/projection.ts:25`](.
SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c`
Sources: [`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
Sources: [`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+10 -10
View File
@@ -977,7 +977,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMemberSnapshot](subsystems/agent-team.zh.md)
来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
<a id="teammessagedelivered--log-only"></a>
@@ -995,7 +995,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageId](subsystems/agent-team.zh.md)
来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts)
<a id="teammessagequeued--log-only"></a>
@@ -1008,7 +1008,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamMessageSnapshot](subsystems/agent-team.zh.md)
来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
<a id="teamtask--log-only"></a>
@@ -1021,7 +1021,7 @@ export type SessionEvent<T extends SessionEventType = SessionEventType> = {
类型:[TeamId](subsystems/agent-team.zh.md) · [TeamTaskSnapshot](subsystems/agent-team.zh.md)
来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
### `todo/*`
@@ -4715,7 +4715,7 @@ SHA-256: `6598cd6da253948d6b207c6374a45335804c79b576866d6215649c2d1bc85e25`
SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e`
来源:[`packages/experimental/agent-team/src/types.ts:106`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:128`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -4735,7 +4735,7 @@ SHA-256: `2eafefdc6f79a4922d68a787a09eaf4dd2b2369c3777fba4c05e800b5678ac8e`
SHA-256: `2152caf1a71e588e4df2358a806dea24936c1c47131134f01a12939d12cdc0c0`
来源:[`packages/experimental/agent-team/src/types.ts:115`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:137`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7375,7 +7375,7 @@ SHA-256: `930a6567a10bb62ddd157bd8abdf4b182810c8c3b49d91309b3b009a5fed9731`
SHA-256: `b1ebd5cda47d90d980bb05bffc3f970c51537b9aff15b3bc3d639811727741b1`
来源:[`packages/experimental/agent-team/src/types.ts:210`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:232`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7403,7 +7403,7 @@ SHA-256: `6bc8e51124d0874bc89e1d0ceff381a9c3cd0fc240667d69f841ce4f60f65fe5`
SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
来源:[`packages/experimental/agent-team/src/types.ts:214`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:236`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7417,7 +7417,7 @@ SHA-256: `ea08ec1df0c33108fd03345b9af199cf04dea450aa045d3c683f216311403403`
SHA-256: `0bcfaf5566d271028429ac7b562cb59053c9d78b8ae14708c35fac835108d1b1`
来源:[`packages/experimental/agent-team/src/types.ts:216`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:238`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
@@ -7563,7 +7563,7 @@ SHA-256: `477614bca595a9e7adb5a1870be446b9a8684f3ba8950259d6289f2d3b74f028`
SHA-256: `a40d12070f6f4a124f32fb3cc86e7857554702e32f0eda3080a25c4b9ac9b18c`
来源:[`packages/experimental/agent-team/src/types.ts:212`](../packages/experimental/agent-team/src/types.ts)
来源:[`packages/experimental/agent-team/src/types.ts:234`](../packages/experimental/agent-team/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+6 -6
View File
@@ -22575,7 +22575,7 @@
},
"names": [],
"sources": [
"packages/experimental/agent-team/src/types.ts:216"
"packages/experimental/agent-team/src/types.ts:238"
]
},
{
@@ -31770,7 +31770,7 @@
"packages/experimental/agent-team/src/types.ts#TeamMessageSource"
],
"sources": [
"packages/experimental/agent-team/src/types.ts:115"
"packages/experimental/agent-team/src/types.ts:137"
]
},
{
@@ -33739,7 +33739,7 @@
"packages/experimental/agent-team/src/types.ts#TeamMessageSnapshot"
],
"sources": [
"packages/experimental/agent-team/src/types.ts:106"
"packages/experimental/agent-team/src/types.ts:128"
]
},
{
@@ -69181,7 +69181,7 @@
},
"names": [],
"sources": [
"packages/experimental/agent-team/src/types.ts:212"
"packages/experimental/agent-team/src/types.ts:234"
]
},
{
@@ -71163,7 +71163,7 @@
},
"names": [],
"sources": [
"packages/experimental/agent-team/src/types.ts:210"
"packages/experimental/agent-team/src/types.ts:232"
]
},
{
@@ -83593,7 +83593,7 @@
},
"names": [],
"sources": [
"packages/experimental/agent-team/src/types.ts:214"
"packages/experimental/agent-team/src/types.ts:236"
]
},
{
+2 -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 docs/subsystems/agent-team.md
agent-team.md: 2f49e164645e6b66bd0da4946c477a09c412ddba
agent-team.zh.md: 24d7d73fdace11ad5e00eb3fb4c90a4abde33810
agent-team.md: 6cb503d130727b4c4694877f83c02f2e0983c8af
agent-team.zh.md: 083b9fafb6f45cd09ded84807da3c8602193b3d4
+49 -9
View File
@@ -2,7 +2,7 @@
English | [中文](agent-team.zh.md)
Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the literal durable forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts).
Types shared by the experimental implicit-root Team domain, model tools, and host adapters. The [Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.md) owns identity, mailbox, task, and shared-checkout decisions; this page records the durable and client-visible forms from [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts).
## Identity and roster
@@ -73,9 +73,56 @@ interface TeamTaskSnapshot {
`pending` is unstarted or released, `in_progress` carries an owner, `completed` satisfies blockers, and `deleted` is a retained tombstone. Views add owner name, readiness, and write-scope overlap warnings without changing the durable snapshot.
<a id="web-projection"></a>
## Web projection
The Lead Session publishes `SessionProjectionMap.agentTeam` with durable roster rows and non-deleted task views. `failure` reports a rejected persisted record beside the last valid state. Member activity comes from Session status; model labels come from each member's `modelSelection` projection.
```ts type-equiv
/** One durable roster row published through the `agentTeam` Session projection. */
interface TeamMemberProjection {
readonly id: SessionId
readonly name: string
readonly role: 'lead' | 'teammate'
/** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */
readonly phase: TeamMemberPhase
readonly error?: string
}
```
```ts type-equiv
/** Runtime-enriched task view returned to tools and hosts. */
interface TeamTaskView {
readonly id: TeamTaskId
readonly revision: number
readonly subject: string
readonly description: string
readonly status: TeamTaskStatus
readonly blockedBy: TeamTaskId[]
readonly writeScopes: string[]
readonly ownerName?: string
readonly ready: boolean
readonly writeScopeWarnings: string[]
}
```
```ts type-equiv
/**
* Durable Team state published to browser clients through the Lead Session's
* `agentTeam` projection. `failure` names the first rejected persisted Team
* record; members and tasks then stay at the last valid state.
*/
interface TeamProjection {
readonly members: TeamMemberProjection[]
readonly tasks: TeamTaskView[]
readonly failure?: string
}
```
## Replay
`foldTeam()` replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior.
The `agentTeam` Session projection replays one root Session into the roster, task board, and queued-minus-delivered mailbox that every Team operation reads. It selects records by `TeamId`, so events inherited by an ordinary fork retain the ancestor id and never enter the new root's state. Session event `seq` and `time` remain the ordering and timing record; Team snapshots do not duplicate them. Roster and task reads reach callers as views; pending mail stays internal to delivery and recovery. The package [README](../../packages/experimental/agent-team/README.md) owns operation, authorization, recovery, and limit behavior.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -176,13 +223,6 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'ina
* @returns Team membership, or undefined for non-Team subagents and stale identities.
*/
tryMembership(agent: Agent): TeamMembership | undefined
/**
* Read the current roster and non-deleted task board through the generated Remote API.
* @param agent - exact live Team member used as the authority credential.
* @returns detached current roster and task views.
*/
@Remote('view') remoteView(agent: Agent): TeamView
```
Types: [Agent](core.md)
+49 -9
View File
@@ -2,7 +2,7 @@
[English](agent-team.md) | 中文
实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的字面持久形式。
实验性隐式 Root Team 领域、模型工具与宿主适配器共享的类型。[Agent Teams Agent Note](../../.agents/notes/implemented/feature/2026-08-05-agent-teams.zh.md)负责身份、mailbox、task 与共享 checkout 决策;本页记录 [`packages/experimental/agent-team/src/types.ts`](../../packages/experimental/agent-team/src/types.ts) 中的持久与客户端可见形式。
## 身份与 roster
@@ -73,9 +73,56 @@ interface TeamTaskSnapshot {
`pending` 表示尚未开始或已经释放,`in_progress` 携带 owner,`completed` 满足 blocker,`deleted` 是保留的 tombstone。view 会添加 owner name、readiness 和 write-scope 重叠警告,但不会改变持久快照。
<a id="web-projection"></a>
## Web 投影
Lead Session 通过 `SessionProjectionMap.agentTeam` 发布持久 roster 行与未删除任务视图。`failure` 在最后有效状态旁报告被拒绝的持久记录。成员活动来自 Session 状态;模型标签来自各成员的 `modelSelection` 投影。
```ts type-equiv
/** One durable roster row published through the `agentTeam` Session projection. */
interface TeamMemberProjection {
readonly id: SessionId
readonly name: string
readonly role: 'lead' | 'teammate'
/** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */
readonly phase: TeamMemberPhase
readonly error?: string
}
```
```ts type-equiv
/** Runtime-enriched task view returned to tools and hosts. */
interface TeamTaskView {
readonly id: TeamTaskId
readonly revision: number
readonly subject: string
readonly description: string
readonly status: TeamTaskStatus
readonly blockedBy: TeamTaskId[]
readonly writeScopes: string[]
readonly ownerName?: string
readonly ready: boolean
readonly writeScopeWarnings: string[]
}
```
```ts type-equiv
/**
* Durable Team state published to browser clients through the Lead Session's
* `agentTeam` projection. `failure` names the first rejected persisted Team
* record; members and tasks then stay at the last valid state.
*/
interface TeamProjection {
readonly members: TeamMemberProjection[]
readonly tasks: TeamTaskView[]
readonly failure?: string
}
```
## 回放
`foldTeam()` 把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。
`agentTeam` Session 投影把一个 Root Session 回放成每个 Team 操作所读取的 roster、任务板与 queued-minus-delivered mailbox。它按 `TeamId` 选取记录,因此普通 fork 继承的 event 保留 ancestor id,绝不会进入新 Root 的状态。Session event 的 `seq` 与 `time` 继续负责顺序和时间记录,Team snapshot 不再重复保存它们。roster 与 task 读取以 view 形式到达调用方,而 pending 邮件仅供投递与恢复内部使用。包 [README](../../packages/experimental/agent-team/README.zh.md)负责 operation、authorization、recovery 和限制行为。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -176,13 +223,6 @@ interrupt(caller: Agent, targetName: string): { previousStatus: 'running' | 'ina
* @returns Team membership, or undefined for non-Team subagents and stale identities.
*/
tryMembership(agent: Agent): TeamMembership | undefined
/**
* Read the current roster and non-deleted task board through the generated Remote API.
* @param agent - exact live Team member used as the authority credential.
* @returns detached current roster and task views.
*/
@Remote('view') remoteView(agent: Agent): TeamView
```
Types: [Agent](core.zh.md)
+2 -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 docs/subsystems/boot.md
boot.md: a1e8917adaf80315d871081740e5a76e5cd1e6dd
boot.zh.md: 1ba455d6ba9ff0053ff9d6f6d1313e7ae871a2a1
boot.md: 6ec2204ffe04fe91acb89fe16fa8f8d3332299e7
boot.zh.md: f5fde00f0cd858450ecd7c75ca42b64e2f4348e4
+4 -2
View File
@@ -129,7 +129,9 @@ Manage profile files and apply their declared reload lifecycle.
@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
/**
* Install a package using the same pnpm implementation as dsh plugin. A run
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
@@ -148,7 +150,7 @@ Manage profile files and apply their declared reload lifecycle.
/** Stop an installation this manager owns and wait until its files are back.
* @param requestId The id the installation was started with.
* @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
* @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
* applied, `not-running` for any other id.
*/
@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>
+4 -2
View File
@@ -129,7 +129,9 @@ Manage profile files and apply their declared reload lifecycle.
@Remote setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult>
/**
* Install a package using the same pnpm implementation as dsh plugin. A run
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
@@ -148,7 +150,7 @@ Manage profile files and apply their declared reload lifecycle.
/** Stop an installation this manager owns and wait until its files are back.
* @param requestId The id the installation was started with.
* @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
* @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
* applied, `not-running` for any other id.
*/
@Remote async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation>
@@ -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/boot/plugin-manager/README.md
README.md: 1de9c9d78e421d44eb006f1ab7fd82656f006a2a
README.zh.md: f7502aab64738ad1feccf58b84ff945213df2eff
README.md: 803566c8650df7186c5344945ea0fe2359e8e70c
README.zh.md: 657e4587e2d813dd53b44fdb15a23f905f2c0dca
+4 -1
View File
@@ -45,11 +45,13 @@ A selected bundle that cannot load remains in `listBundles` with an `error`; `en
`inspect(spec, options)` reads what a spec names before anything installs: a registry name is asked of the registry through `pnpm view`, run in the profile directory so the same proxy and authentication settings apply as to the install; an absolute path has its `package.json` read; a git address or tarball answers only its form and the `host` it is fetched from. The answer carries the name, version, description, whether the package declares a bundle, and the `registry` that answered, or a `problem`: `invalid-spec`, `already-installed`, `not-found`, `not-a-package`, `not-a-bundle`, `network`, or `unknown`, with the `registries` asked. A caller's `signal` or `inspectTimeoutMs` ends the lookup.
`installBundle` checks GitHub repositories with `git ls-remote` before starting pnpm, using the profile directory and the installer's Git and proxy configuration. `githubConnectionTimeoutMs` defaults to 5000 ms and limits only this check, not package download or builds. The check disables credential helpers and prompts; only network failures and timeouts stop installation, reporting `failedAt: 'spec-host'` with the existing failure kind and diagnostic log. Authentication, repository lookup and other failures are left to pnpm, including its HTTPS-to-SSH fallback. Cancellation and manager disposal stop the check and its descendants. Registry packages, paths, tarballs and other Git hosts skip this check. A reachable repository can still fail during download or bundle validation.
Registries are asked in turn. The plan starts at `options.registry`, else the configured `registry` (`null` is the one pnpm's own configuration names), and continues through `fallbackRegistries` while a registry is unreachable, times out, or answers that it has no such package or version, which a mirror not yet synced does. A registry outside the configured set is asked alone, so a private registry never falls through to a public one; pnpm's own registry counts as part of the set only while what it names, read through `pnpm config get registry` before each plan, is npm's own registry or one of the fallbacks, and is otherwise asked alone as a private one. A registry pnpm's own configuration already names is asked once. The lookup runs `pnpm view` with `--registry` and without pnpm's own retries, so a dead registry is reported within `inspectTimeoutMs` and the next one is asked; pnpm prints its refusal as JSON on stdout, which is read like stderr. The install keeps pnpm's retry settings. `registries()` answers the configured set and what pnpm names, for a picker. The [registry Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.md) owns the rationale.
The browser-safe `@deepseek-ai/dsh-plugin-manager/registry` entry exports `OFFICIAL_NPM_REGISTRY` and `NPMMIRROR_REGISTRY` for consumers that identify those public registries.
`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read.
`installBundle` accepts a caller-generated `requestId`, under which `plugin-manager/install-log` streams each pnpm run's output and `plugin-manager/install-state` announces `installing`, `cancelling`, and `applying`; `installing` is announced once per registry asked, with the `attempt`'s registry, position, and the plan's length. The install asks the same plan as the lookup, from `options.registry`, running `pnpm add` with `--registry` and restoring the profile files between attempts; it moves on for the failures another registry can change and stops at any other, and at a failure whose error line names the host a git or tarball spec is fetched from, which no registry stands in for; `failedAt` says which of the two the last failed run could not reach. `cancelInstall(requestId)` stops the run and answers `cancelled` only after the Git check or pnpm exited and the files are back, `too-late` once the bundle is being applied, and `not-running` for any other id; the install call then reports `application: 'cancelled'`. A run that fails, is cancelled, or adds a package without a bundle patch restores `package.json` and `pnpm-lock.yaml` as they were; `packageResult.kind` classifies the last run from its exit and output, `registries` names every registry asked, and `bundle` names the package a finished run added. `listBundles` carries each bundle's one-liner (the package `description`), the rows its patch declares with their live entries, and the built-in rows it overrides; it lists the profile's own bundles, the bundles the installation supplies, and a selected name without a bundle patch as a `not-bundle` problem, while an unselected plain dependency is left out. A bundle the launcher's `OPTIONAL_BUNDLES` names is `optional`: shipped switched off for the person to turn on, never removable, and selected by no shipped template ([rationale](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.md)). Every completed operation emits `plugin-manager/changed`; a patch generation applied outside the manager, by HMR's watcher after a CLI or hand edit, announces nothing, so the page learns of it on its next read.
`waitForInstall(requestId)` lets a client recover a lost response by waiting for the active installation, including its non-cancellable application phase. It returns the same outcome as the original call, or `null` if the request is not active. Completed results are not retained; `null` establishes neither success nor cancellation.
@@ -61,6 +63,7 @@ When pnpm 11 blocks dependency scripts, the failed installation reports every pe
|---|---|---|
| `pnpmCommand` | `pnpm` | The pnpm executable name or path, resolved through `PATH` like the `dsh plugin` command. |
| `inspectTimeoutMs` | `20000` | Bound on one registry lookup an inspection runs, in milliseconds. |
| `githubConnectionTimeoutMs` | `5000` | Deadline for the GitHub repository check before installation, in milliseconds. |
| `registry` | pnpm's own | The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. |
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | Registries asked in turn, as http(s) URLs, while the one before is unreachable or holds no copy of the package; pnpm's own registry joins the order only while it names npm's own registry or one of these. |
| `outputBytes` | `16384` | Maximum pnpm diagnostic bytes returned per operation; the full output remains in the returned log path. |
+4 -1
View File
@@ -45,11 +45,13 @@ kind: "package-reference"
`inspect(spec, options)` 在任何东西安装之前读出 spec 指向什么:注册表包名通过 `pnpm view` 询问注册表,在 profile 目录中运行,因而与安装使用同样的代理与认证设置;绝对路径读取其 `package.json`;git 地址或 tarball 只答复自己的形式和它被拉取的 `host`。答复携带名称、版本、描述、该包是否声明组合包,以及作答的 `registry`,否则给出 `problem`:`invalid-spec`、`already-installed`、`not-found`、`not-a-package`、`not-a-bundle`、`network` 或 `unknown`,并附上问过的 `registries`。调用方的 `signal` 或 `inspectTimeoutMs` 会结束查询。
`installBundle` 在启动 pnpm 前通过 `git ls-remote` 检查 GitHub 仓库,使用 profile 目录及安装器的 Git 与代理配置。`githubConnectionTimeoutMs` 默认为 5000 毫秒,只限制这次检查,不限制包下载或构建。检查禁用凭据助手和认证提示;只有网络失败与超时会停止安装,通过现有失败类型与诊断日志返回,并标记 `failedAt: 'spec-host'`。认证、仓库查找及其他失败继续交给 pnpm,包括其 HTTPS 到 SSH 的回退。取消安装或销毁管理器会停止检查及其子进程。注册表包、本地路径、压缩包和其他 Git 主机跳过此检查。仓库可达后,下载或组合包验证仍可能失败。
注册表按顺序询问。计划从 `options.registry` 开始,否则从配置的 `registry`(`null` 即 pnpm 自身配置指定的那个)开始,并在一个注册表不可达、超时或答复没有这个包或版本(尚未同步的镜像会如此)时继续问 `fallbackRegistries`。配置集合之外的注册表只问它自己,因而私有源永远不会落到公共源;pnpm 自身的注册表只在它指向的地址(每次做计划前经 `pnpm config get registry` 读出)是 npm 官方源或某个备选源时才算集合成员,否则视为私有源只问它自己。pnpm 自身配置已经指向的注册表只问一次。查询以 `--registry` 运行 `pnpm view` 且不带 pnpm 自身的重试,所以死掉的注册表会在 `inspectTimeoutMs` 内报告并转问下一个;pnpm 把拒绝以 JSON 打印在 stdout,读法与 stderr 相同。安装保留 pnpm 的重试设置。`registries()` 把配置集合和 pnpm 指向的地址答复给选择器。[注册表 Agent Note](../../../.agents/notes/implemented/architecture/2026-09-18-plugin-install-registries.zh.md) 拥有理由。
可在浏览器使用的 `@deepseek-ai/dsh-plugin-manager/registry` 入口导出 `OFFICIAL_NPM_REGISTRY` 和 `NPMMIRROR_REGISTRY`,供消费方识别这两个公共注册表。
`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。
`installBundle` 接受调用方生成的 `requestId`,`plugin-manager/install-log` 在其下流式转发每次 pnpm 运行的输出,`plugin-manager/install-state` 通告 `installing`、`cancelling` 与 `applying`;每问一个注册表通告一次 `installing`,`attempt` 带上这次的注册表、序号和计划长度。安装沿用查询的计划,从 `options.registry` 开始,以 `--registry` 运行 `pnpm add`,两次尝试之间恢复 profile 文件;换一个注册表能改变的失败才转问下一个,其他失败即停,错误行点名 git 或 tarball spec 自身拉取主机的失败也停,因为没有注册表能替代那台主机;`failedAt` 说明最后一次失败的运行连不上的是二者中的哪一个。`cancelInstall(requestId)` 停止运行,只在 Git 检查或 pnpm 退出且文件恢复后答复 `cancelled`,组合包已在应用时答复 `too-late`,其他 id 答复 `not-running`;安装调用随后报告 `application: 'cancelled'`。失败、被取消或装入了没有组合包 patch 的包的运行,会把 `package.json` 与 `pnpm-lock.yaml` 恢复原样;`packageResult.kind` 按退出方式与输出对最后一次运行分类,`registries` 列出问过的每个注册表,`bundle` 给出完成的运行新增的包。`listBundles` 携带每个组合包的一句话简介(包的 `description`)、其 patch 声明的行及其存活条目,以及它覆盖的内置行;它列出 profile 自己的组合包、安装提供的组合包,以及被选中却没有组合包 patch 的名字(作为 `not-bundle` 问题),未选中的普通依赖不列出。启动器的 `OPTIONAL_BUNDLES` 点名的组合包是 `optional`:随安装提供、默认关闭、由用户开启,永不可卸载,也不被任何随附模板选中([理由](../../../.agents/notes/implemented/process/2026-09-15-shipped-optional-bundles.zh.md))。每个完成的操作都会发出 `plugin-manager/changed`;在管理器之外应用的一代 patch(HMR 监视到 CLI 或手工编辑后)不发通知,页面要到下一次读取才知道。
`waitForInstall(requestId)` 让客户端在响应丢失后等待活动安装的结果,包括不可取消的应用阶段。它返回与原调用相同的结果,请求不在活动中时返回 `null`。已完成的结果不保留;`null` 不表示成功或已取消。
@@ -61,6 +63,7 @@ pnpm 11 拦下依赖脚本时,失败的安装在 `pendingBuilds` 里报告 pro
|---|---|---|
| `pnpmCommand` | `pnpm` | pnpm 可执行文件名或路径,与 `dsh plugin` 命令一样通过 `PATH` 解析。 |
| `inspectTimeoutMs` | `20000` | 单次检查所做注册表查询的上限,单位毫秒。 |
| `githubConnectionTimeoutMs` | `5000` | 安装前 GitHub 仓库连接检查的时限,单位毫秒。 |
| `registry` | pnpm 自身配置 | 查询与安装首先询问的注册表,http(s) URL;缺省为 pnpm 自身配置指定的那个。 |
| `fallbackRegistries` | `['https://registry.npmmirror.com/']` | 前一个注册表不可达或没有该包副本时依次询问的注册表,http(s) URL;pnpm 自身的注册表只在它指向 npm 官方源或这里的某一个时才进入顺序。 |
| `outputBytes` | `16384` | 每次操作返回的 pnpm 诊断字节上限;完整输出保留在返回的日志路径中。 |
@@ -0,0 +1,68 @@
/** Bounded GitHub repository checks using the installer's Git configuration and environment. */
import { mkdir, mkdtemp, open } from 'node:fs/promises'
import { join } from 'node:path'
import { execa } from 'execa'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import { classifyInstallFailure } from './install-failure.ts'
import type { ParsedInstallSpec } from './install-spec.ts'
import type { PackageResult } from './types.ts'
/** Deadline, diagnostics and cancellation for one read-only GitHub check. */
export interface GithubConnectionOptions {
timeoutMs: number
outputBytes: number
signal: AbortSignal
/** The application-owned package manager's environment, also used by its Git children. */
env?: Readonly<Record<string, string>>
}
/**
* Check a GitHub repository before pnpm starts, without downloading or building its package.
* Git reads the profile's Git and proxy configuration without invoking credential helpers or prompting.
* Timeout and cancellation terminate its descendants too; pnpm owns authentication and transport fallback.
* @param spec The parsed installation address; npm packages, local paths and other hosts are not checked.
* @param dir The profile directory where installation runs.
* @param options The connection deadline, output bound and operation cancellation.
* @returns A failed check with bounded output and a complete log at logPath, or undefined for a reachable repository or an unhandled spec.
*/
export async function checkGithubConnection(
spec: ParsedInstallSpec, dir: string, options: GithubConnectionOptions,
): Promise<PackageResult | undefined> {
if (spec.kind !== 'git') return undefined
const host = spec.host.toLowerCase().replace(/:.*$/, '')
if (host !== 'github.com' && !host.endsWith('.github.com')) return undefined
const repository = spec.spec.replace(/#.*$/s, '')
.replace(/^github:/i, 'https://github.com/')
.replace(/^gist:/i, 'https://gist.github.com/')
.replace(/^git\+/i, '')
const logRoot = join(dir, '.plugin-manager', 'logs')
await mkdir(logRoot, { recursive: true, mode: 0o700 })
const logDir = await mkdtemp(join(logRoot, 'github-connection-'))
const logPath = join(logDir, 'git.log')
const log = await open(logPath, 'ax+', 0o600)
try {
const result = await execa('git', ['-c', 'credential.helper=', 'ls-remote', '--', repository, 'HEAD'], {
cwd: dir,
env: {
...scrubbedParentEnv(), ...options.env, LC_ALL: 'C', GIT_TERMINAL_PROMPT: '0',
GIT_ASKPASS: '', SSH_ASKPASS: '', SSH_ASKPASS_REQUIRE: 'never',
},
extendEnv: false,
stdin: 'ignore', stdout: 'ignore', stderr: { file: logPath, append: true }, buffer: false, reject: false,
timeout: options.timeoutMs, cancelSignal: options.signal, killDescendants: true, killSignal: 'SIGKILL',
})
if (!result.failed) return undefined
if (result.timedOut) await log.write(`dsh: connection to ${spec.host} timed out after ${String(options.timeoutMs)}ms\n`)
if ((await log.stat()).size === 0) await log.write(result.shortMessage ?? 'GitHub connection check failed')
const { size } = await log.stat()
const bytes = Buffer.alloc(Math.min(size, options.outputBytes))
await log.read(bytes, 0, bytes.length, size - bytes.length)
const output = bytes.toString('utf8')
return {
exitCode: result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1), output, truncated: size > options.outputBytes, logPath,
kind: classifyInstallFailure({ log: output, timedOut: result.timedOut }),
}
} finally {
await log.close()
}
}
+31 -8
View File
@@ -24,6 +24,7 @@ import { attributeFailure, normalizeRegistry, NPMMIRROR_REGISTRY, registryPlan }
import { writePluginEnabled } from './patch.ts'
import { ManagementFailure } from './failure.ts'
import { approveBuilds, readPendingBuilds } from './build-approval.ts'
import { checkGithubConnection } from './github-connection.ts'
import type {
BundleInfo, BundleRowInfo, ChangeResult, InspectOptions, InstallBundleOptions, ManagementError, PackageResult, PluginChange,
PluginEntryId, PluginInfo, PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId,
@@ -33,16 +34,18 @@ export type * from './types.ts'
export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
/** The pnpm executable, the registries asked, and the limits for package diagnostics and registry lookups. */
/** The pnpm executable, registries, and limits for diagnostics, lookups and connection checks. */
export interface Config {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
pnpmCommand?: string
/** Maximum retained pnpm diagnostic bytes per operation. */
/** Maximum retained package-operation diagnostic bytes. */
outputBytes?: number
/** Maximum time to wait for another process's profile package operation. */
lockWaitMs?: number
/** Bound on one registry lookup an inspection runs, in milliseconds. */
inspectTimeoutMs?: number
/** Maximum duration of the GitHub repository connection check before installation, in milliseconds. */
githubConnectionTimeoutMs?: number
/** The registry lookups and installations ask first, as an http(s) URL; absent, the one pnpm's own configuration names. */
registry?: string
/**
@@ -172,6 +175,7 @@ export class PluginManager extends TypertRemoteService {
outputBytes: z.number().step(1).min(1).default(16384),
lockWaitMs: z.number().step(1).min(0).default(120000),
inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
githubConnectionTimeoutMs: z.number().step(1).min(1000).default(5000),
registry: z.string().pattern(REGISTRY_URL),
fallbackRegistries: z.array(z.string().pattern(REGISTRY_URL)).default([NPMMIRROR_REGISTRY]),
})
@@ -183,6 +187,7 @@ export class PluginManager extends TypertRemoteService {
private readonly outputBytes: number
private readonly lockWaitMs: number
private readonly inspectTimeoutMs: number
private readonly githubConnectionTimeoutMs: number
private readonly pnpmCommand: string
private readonly configuredRegistries: Omit<PluginRegistries, 'resolved'>
private readonly ownerContext: Context
@@ -199,6 +204,7 @@ export class PluginManager extends TypertRemoteService {
this.outputBytes = (config as Required<Config>).outputBytes
this.lockWaitMs = (config as Required<Config>).lockWaitMs
this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
this.githubConnectionTimeoutMs = (config as Required<Config>).githubConnectionTimeoutMs
this.pnpmCommand = (config as Required<Config>).pnpmCommand
this.configuredRegistries = {
registry: config.registry === undefined ? null : normalizeRegistry(config.registry),
@@ -406,7 +412,9 @@ export class PluginManager extends TypertRemoteService {
}
/**
* Install a package using the same pnpm implementation as dsh plugin. A run
* Install a package using the same pnpm implementation as dsh plugin. GitHub
* repositories get a connection check bounded by githubConnectionTimeoutMs before pnpm starts;
* only network failures or timeouts stop installation, while pnpm owns authentication and transport fallback. A run
* that fails, is cancelled, or adds a package without a bundle patch restores
* `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
* @param spec One package spec, including local paths relative to the invocation directory.
@@ -418,7 +426,7 @@ export class PluginManager extends TypertRemoteService {
installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult> {
const requestId = options?.requestId
const control: InstallControl = { abort: new AbortController(), phase: 'installing', result: Promise.resolve(null) }
const stopped = (): boolean => control.abort.signal.aborted
const stopped = (): boolean => control.abort.signal.aborted || this.abort.signal.aborted
if (requestId !== undefined) this.installs.set(requestId, control)
const announce = (phase: PluginInstallProgress['phase'], attempt?: PluginInstallProgress['attempt']): void => {
if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase, ...attempt === undefined ? {} : { attempt } })
@@ -432,12 +440,27 @@ export class PluginManager extends TypertRemoteService {
}
const files = await this.readRestoredFiles()
const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
const plan = registryPlan(options?.registry, await this.registries())
let name: string
try {
// The last run is the result's; the registries asked stay listed whatever the outcome.
let run: PackageResult | undefined
result.registries = []
const connection = checkGithubConnection(parsedForRegistry(spec), this.profile.dir, {
timeoutMs: this.githubConnectionTimeoutMs, outputBytes: this.outputBytes,
signal: AbortSignal.any([this.abort.signal, control.abort.signal]),
...this.profile.packageManager?.env === undefined ? {} : { env: this.profile.packageManager.env },
})
this.packageOperations.add(connection)
let connectionFailure: PackageResult | undefined
try { connectionFailure = await connection }
finally { this.packageOperations.delete(connection) }
if (stopped()) throw new InstallCancelledError()
if (connectionFailure?.kind === 'network' || connectionFailure?.kind === 'timeout') {
result.packageResult = connectionFailure
result.failedAt = 'spec-host'
throw new Error(connectionFailure.output)
}
// The last run is the result's; the registries asked stay listed whatever the outcome.
const plan = registryPlan(options?.registry, await this.registries())
let run: PackageResult | undefined
for (const [index, registry] of plan.entries()) {
if (index > 0) await this.restoreFiles(files)
// A stop that landed while the files went back, or before the first run, starts no run with a dead signal.
@@ -509,7 +532,7 @@ export class PluginManager extends TypertRemoteService {
/** Stop an installation this manager owns and wait until its files are back.
* @param requestId The id the installation was started with.
* @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
* @returns `cancelled` once the Git check or pnpm exited and the files are restored, `too-late` once the bundle is being
* applied, `not-running` for any other id.
*/
@Remote
@@ -25,6 +25,7 @@ const LOG_KINDS: readonly [PluginInstallFailureKind, RegExp][] = [
['disk-full', /\bENOSPC\b|no space left on device/i],
['permission', /\bEACCES\b|\bEPERM\b|permission denied/i],
['integrity', /ERR_PNPM_TARBALL_INTEGRITY|ERR_PNPM_BAD_TARBALL_SIZE|\bEINTEGRITY\b/],
['unknown', /The requested URL returned error: 40[134]\b/],
['network', /\bENOTFOUND\b|\bECONNRESET\b|\bETIMEDOUT\b|\bECONNREFUSED\b|\bEAI_AGAIN\b|ERR_PNPM_META_FETCH_FAIL|ERR_PNPM_FETCH_5\d\d|ERR_PNPM_FETCH_TIMEOUT|\bFETCH_ERROR\b|socket hang up|Could not resolve host|unable to access/],
]
+2 -2
View File
@@ -69,7 +69,7 @@ export interface PluginRegistries {
readonly resolved: string | null
}
/** How a pnpm run failed, read off how it ended and what it printed. */
/** How a package operation failed, read off how it ended and what it printed. */
export type PluginInstallFailureKind =
| 'pnpm-missing'
| 'timeout'
@@ -82,7 +82,7 @@ export type PluginInstallFailureKind =
| 'integrity'
| 'unknown'
/** Pnpm completion, including a retrieval path for unabridged diagnostics. */
/** Package operation completion, including Git checks and a retrieval path for unabridged diagnostics. */
export interface PackageResult {
exitCode: number
output: string
@@ -0,0 +1,183 @@
/** Real Git transport deadlines, process cleanup, and bounded connection diagnostics. */
import { once } from 'node:events'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { appendFileSync } from 'node:fs'
import { createServer, type Socket } from 'node:net'
import { createServer as createHttpServer } from 'node:http'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import * as subprocess from 'execa'
import { expect, it, onTestFinished, vi } from 'vitest'
import { checkGithubConnection } from '../src/github-connection.ts'
import { parseInstallSpec } from '../src/install-spec.ts'
vi.mock('execa', async (original) => {
const module = await original<typeof import('execa')>()
return { ...module, execa: vi.fn(module.execa) }
})
async function fixture() {
const dir = await mkdtemp(join(tmpdir(), 'dsh-github-check-'))
onTestFinished(() => rm(dir, { recursive: true, force: true }))
const controller = new AbortController()
const tasks: Promise<unknown>[] = []
onTestFinished(async () => { controller.abort(); await Promise.allSettled(tasks) })
const config = join(dir, 'git.config')
await writeFile(config, '')
const env = { GIT_CONFIG_GLOBAL: config, GIT_CONFIG_NOSYSTEM: '1' }
const check = (spec: string, timeoutMs = 3000, outputBytes = 4096) => {
const task = checkGithubConnection(parseInstallSpec(spec), dir, {
timeoutMs, outputBytes, signal: controller.signal,
env,
})
tasks.push(task)
return task
}
return { dir, config, controller, check, env }
}
async function proxy(config: string, fail: boolean) {
const sockets = new Set<Socket>()
const connected = Promise.withResolvers<undefined>()
const disconnected = Promise.withResolvers<undefined>()
const server = createServer((socket) => {
sockets.add(socket)
socket.on('error', () => {})
socket.once('close', () => { sockets.delete(socket); disconnected.resolve(undefined) })
socket.once('data', () => { connected.resolve(undefined); if (fail) socket.destroy() })
})
onTestFinished(async () => {
for (const socket of sockets) socket.destroy()
await new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port')
await writeFile(config, `[http "https://github.com/"]\n proxy = http://127.0.0.1:${String(address.port)}\n`)
return { connected: connected.promise, disconnected: disconnected.promise, sockets }
}
it('leaves registry packages and other Git hosts to the installer', async () => {
const { check, dir } = await fixture()
expect(await check('@acme/plugin')).toBeUndefined()
expect(await check('https://gitlab.com/acme/plugin.git')).toBeUndefined()
await expect(readFile(join(dir, '.plugin-manager', 'logs'))).rejects.toMatchObject({ code: 'ENOENT' })
})
it.each([
['https://github.com/acme/plugin.git#main', 'https://github.com/'],
['github:acme/plugin#main', 'https://github.com/'],
['git+https://github.com/acme/plugin.git#main', 'https://github.com/'],
['git+ssh://[email protected]:22/acme/plugin.git#main', 'ssh://[email protected]:22/'],
['[email protected]:acme/plugin.git#main', '[email protected]:'],
['gist:acme/plugin#main', 'https://gist.github.com/'],
])('checks %s through the profile Git configuration without downloading a package', async (spec, prefix) => {
const { check, dir, config } = await fixture()
const repository = join(dir, 'empty.git')
await subprocess.execa('git', ['init', '--bare', repository])
// Match both forms exactly; a fragment reaching Git would fail to resolve this repository.
await writeFile(config, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ${prefix}acme/plugin.git\n insteadOf = ${prefix}acme/plugin\n`)
expect(await check(spec)).toBeUndefined()
})
it('reports an immediate transport failure without waiting for the deadline', async () => {
const { check, config } = await fixture()
const transport = await proxy(config, true)
const result = await check('https://github.com/acme/plugin.git')
await transport.connected
expect(result).toMatchObject({ exitCode: 128, kind: 'network', truncated: false })
expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output)
await transport.disconnected
expect(transport.sockets.size).toBe(0)
})
it('ends a silent proxy connection and its Git child at the connection deadline', async () => {
const { check, config } = await fixture()
const transport = await proxy(config, false)
const running = check('https://github.com/acme/plugin.git')
await transport.connected
const result = await running
expect(result?.kind).toBe('timeout')
expect(result!.output).toContain('connection to github.com timed out after 3000ms')
await transport.disconnected
expect(transport.sockets.size).toBe(0)
})
it('cancels a connected Git probe and closes its transport before the deadline', async () => {
const { check, config, controller } = await fixture()
const transport = await proxy(config, false)
const running = check('https://github.com/acme/plugin.git', 60000)
await transport.connected
controller.abort()
const result = await running
expect(result?.kind).toBe('unknown')
await transport.disconnected
expect(transport.sockets.size).toBe(0)
})
it.each([undefined, 'Git could not start'])('retains a diagnostic when Git writes no stderr (%s)', async (shortMessage) => {
const { check } = await fixture()
const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, shortMessage } as Awaited<ReturnType<typeof subprocess.execa>>)
onTestFinished(() => { run.mockRestore() })
const result = await check('github:acme/plugin')
expect(result).toMatchObject({ exitCode: 1, kind: 'unknown', output: shortMessage ?? 'GitHub connection check failed' })
expect(await readFile(result!.logPath, 'utf8')).toBe(result!.output)
})
it('bounds returned diagnostics while retaining Git stderr in the operation log', async () => {
const { check } = await fixture()
const run = vi.spyOn(subprocess, 'execa').mockImplementation((...args: unknown[]) => {
const options = args[2] as { stderr: { file: string } }
appendFileSync(options.stderr.file, '0123456789')
return Promise.resolve({ failed: true, exitCode: 128 }) as ReturnType<typeof subprocess.execa>
})
onTestFinished(() => { run.mockRestore() })
const result = await check('github:acme/plugin', 3000, 4)
expect(result).toMatchObject({ output: '6789', truncated: true })
expect(await readFile(result!.logPath, 'utf8')).toBe('0123456789')
})
it('reports a missing Git executable without classifying it as a network failure', async () => {
const { check } = await fixture()
const run = vi.spyOn(subprocess, 'execa').mockResolvedValue({ failed: true, code: 'ENOENT', shortMessage: 'spawn git ENOENT' } as Awaited<ReturnType<typeof subprocess.execa>>)
onTestFinished(() => { run.mockRestore() })
expect(await check('github:acme/plugin')).toMatchObject({ exitCode: 127, kind: 'unknown', output: 'spawn git ENOENT' })
})
it('does not invoke configured credential helpers or askpass for an authentication challenge', async () => {
const { check, dir, config, env } = await fixture()
const server = createHttpServer((_request, response) => {
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
response.end()
})
onTestFinished(async () => {
server.closeAllConnections()
await new Promise<void>((resolve, reject) => { server.close((error) => { if (error) reject(error); else resolve() }) })
})
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const address = server.address()
if (address === null || typeof address === 'string') throw new Error('server did not bind a TCP port')
const marker = join(dir, 'prompted')
const helper = join(dir, 'credential-helper.cjs')
await writeFile(helper, `require('node:fs').appendFileSync(${JSON.stringify(marker)}, process.argv.slice(2).join(' ') + '\\n')`)
const helperCommand = `"${process.execPath.replaceAll('\\', '/')}" "${helper.replaceAll('\\', '/')}"`
const askpass = join(dir, 'askpass.sh')
await writeFile(askpass, `#!/bin/sh\nexec ${helperCommand} "$@"\n`, { mode: 0o700 })
await writeFile(config, `[url "http://127.0.0.1:${String(address.port)}/"]\n insteadOf = https://github.com/\n[http]\n proxy =\n`)
await subprocess.execa('git', ['config', '--file', config, 'credential.helper', `!${helperCommand}`])
await subprocess.execa('git', ['config', '--file', config, 'core.askPass', askpass])
Object.assign(env, { GIT_ASKPASS: askpass, SSH_ASKPASS: askpass })
// The same Git configuration really prompts without the probe's noninteractive overrides.
await subprocess.execa('git', ['ls-remote', '--', 'https://github.com/acme/private.git', 'HEAD'], {
cwd: dir, env: { ...env, GIT_TERMINAL_PROMPT: '0' }, reject: false,
})
const prompted = await readFile(marker, 'utf8')
expect(prompted).toContain('get')
expect(prompted).toContain('Username')
await rm(marker)
expect(await check('github:acme/private.git')).toMatchObject({ exitCode: 128, kind: 'unknown' })
await expect(readFile(marker)).rejects.toMatchObject({ code: 'ENOENT' })
})
@@ -56,6 +56,10 @@ describe('parseInstallSpec', () => {
})
describe('classifyInstallFailure', () => {
it.each([401, 403, 404])('leaves Git HTTP %s responses out of connection recovery', (status) => {
expect(classifyInstallFailure({ log: `fatal: unable to access 'https://github.com/acme/private.git/': The requested URL returned error: ${String(status)}` })).toBe('unknown')
})
it('names the run\'s end before reading its output, then the most specific code in the output', () => {
expect(classifyInstallFailure({ log: 'ENOSPC', timedOut: true })).toBe('timeout')
expect(classifyInstallFailure({ log: '', cause: Object.assign(new Error('spawn pnpm ENOENT'), { code: 'ENOENT' }) })).toBe('pnpm-missing')
@@ -1,9 +1,13 @@
/** Persistent manager behavior through a real profile Include and Loader. */
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { realpath } from 'node:fs/promises'
import { once } from 'node:events'
import { createServer } from 'node:http'
import type { Socket } from 'node:net'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { execa } from 'execa'
import type { Context } from '@deepseek-ai/cordis'
import { expect, it, onTestFinished, vi } from 'vitest'
import {
@@ -17,6 +21,7 @@ import Timer from '@deepseek-ai/cordis-plugin-timer'
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
import { Group } from '@deepseek-ai/cordis-plugin-loader'
import * as operations from '../src/operations.ts'
import * as githubConnection from '../src/github-connection.ts'
import { parse, parseDocument } from 'yaml'
async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, prepare?: (ctx: Context) => void, config: Config = {}, packageManager?: ProfileContext['packageManager'], prepareFiles?: (dir: string) => void) {
@@ -60,7 +65,8 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
})
// What pnpm's own configuration names is read from pnpm before every registry plan; the fixture answers npm's own registry.
const registry = vi.spyOn(operations, 'readProfileRegistry').mockResolvedValue('https://registry.npmjs.org/')
onTestFinished(() => { registry.mockRestore() })
const connection = vi.spyOn(githubConnection, 'checkGithubConnection').mockResolvedValue(undefined)
onTestFinished(() => { registry.mockRestore(); connection.mockRestore() })
let stopHmr = async () => {}
if (reload === 'live') {
await ctx.plugin(Timer)
@@ -68,9 +74,155 @@ async function fixture(reload: 'live' | 'startup' = 'live', overlay = false, pre
stopHmr = () => owner.dispose()
await ctx.hmr.runExclusive(async () => {})
}
return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays }
return { ctx, dir, manager: ctx.pluginManager, bundle, profile, stopHmr, overlays, connection }
}
it.each(['network', 'timeout'] as const)('stops a GitHub %s before pnpm and attributes it to the repository', async (kind) => {
const { manager, dir, connection } = await fixture()
const before = readFileSync(join(dir, 'package.json'), 'utf8')
const failure = { exitCode: 1, output: 'GitHub check failed', truncated: false, logPath: join(dir, 'git.log'), kind }
connection.mockResolvedValue(failure)
const pnpm = vi.spyOn(operations, 'runProfilePnpm')
onTestFinished(() => { pnpm.mockRestore() })
expect(await manager.installBundle('https://github.com/acme/dsh-plugin.git')).toMatchObject({
application: 'failed', changed: false, stage: 'install', failedAt: 'spec-host', packageResult: failure,
})
expect(pnpm).not.toHaveBeenCalled()
expect(connection).toHaveBeenCalledWith(expect.objectContaining({ host: 'github.com' }), dir, expect.objectContaining({ timeoutMs: 5000 }))
expect(readFileSync(join(dir, 'package.json'), 'utf8')).toBe(before)
})
it('leaves non-network GitHub errors to pnpm and forwards profile Git environment and deadline', async () => {
const env = { GIT_CONFIG_GLOBAL: '/application/git.config' }
const { manager, connection } = await fixture('live', false, undefined, { githubConnectionTimeoutMs: 8000 }, { command: 'pnpm', args: [], env })
connection.mockResolvedValue({ exitCode: 128, output: 'fatal: Authentication failed', truncated: false, logPath: 'git.log', kind: 'unknown' })
const failure = { exitCode: 1, output: 'pnpm owns authentication', truncated: false, logPath: 'pnpm.log', kind: 'unknown' as const }
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockResolvedValue(failure)
onTestFinished(() => { pnpm.mockRestore() })
const result = await manager.installBundle('github:acme/private-plugin')
expect(result.application).toBe('failed')
expect(result.packageResult).toEqual(failure)
expect(pnpm).toHaveBeenCalledOnce()
expect(result.failedAt).toBeUndefined()
expect(connection).toHaveBeenCalledWith(expect.anything(), expect.anything(), expect.objectContaining({ timeoutMs: 8000, env }))
})
it('cancels an active GitHub check before starting pnpm', async () => {
const { manager, connection } = await fixture()
const entered = Promise.withResolvers<undefined>()
connection.mockImplementation(async (_spec, _dir, options) => {
entered.resolve(undefined)
await new Promise<void>((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) })
return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' }
})
const pnpm = vi.spyOn(operations, 'runProfilePnpm')
onTestFinished(() => { pnpm.mockRestore() })
const requestId = '824103ec-bc45-489d-bb85-5b4fe0aefc78' as PluginInstallRequestId
const installing = manager.installBundle('github:acme/dsh-plugin', { requestId })
await entered.promise
expect(await manager.cancelInstall(requestId)).toEqual({ status: 'cancelled' })
expect(await installing).toMatchObject({ application: 'cancelled', changed: false })
expect(pnpm).not.toHaveBeenCalled()
})
it('waits for the GitHub check before installing and activating the bundle', async () => {
const { manager, dir, bundle, connection } = await fixture()
const entered = Promise.withResolvers<undefined>()
const release = Promise.withResolvers<undefined>()
connection.mockImplementation(async () => { entered.resolve(undefined); await release.promise; return undefined })
const pnpm = vi.spyOn(operations, 'runProfilePnpm').mockImplementation(async () => {
bundle('addon', [])
const manifest = readProfileManifest('test', dir)
manifest.dependencies = { ...manifest.dependencies, addon: '1.0.0' }
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
return { exitCode: 0, output: 'installed', truncated: false, logPath: join(dir, 'pnpm.log') }
})
const installing = manager.installBundle('github:acme/addon')
onTestFinished(async () => { release.resolve(undefined); await installing; pnpm.mockRestore() })
await entered.promise
expect(pnpm).not.toHaveBeenCalled()
release.resolve(undefined)
expect(await installing).toMatchObject({ application: 'applied', changed: true, bundle: 'addon' })
expect(readProfileManifest('test', dir).dsh?.profile?.bundles).toContain('addon')
})
it('disposal waits for an active GitHub check to stop', async () => {
const { ctx, manager, connection } = await fixture('startup')
const entered = Promise.withResolvers<undefined>()
const released = Promise.withResolvers<undefined>()
connection.mockImplementation(async (_spec, _dir, options) => {
entered.resolve(undefined)
await new Promise<void>((resolve) => { options.signal.addEventListener('abort', () => { resolve() }, { once: true }) })
released.resolve(undefined)
return { exitCode: 1, output: 'cancelled', truncated: false, logPath: 'git.log', kind: 'unknown' }
})
const pnpm = vi.spyOn(operations, 'runProfilePnpm')
onTestFinished(() => { pnpm.mockRestore() })
const installing = manager.installBundle('github:acme/addon')
await entered.promise
await ctx.fiber.dispose()
await released.promise
expect(await installing).toMatchObject({ application: 'cancelled', changed: false })
expect(pnpm).not.toHaveBeenCalled()
})
it.each(['github:acme/connected', 'https://github.com/acme/connected.git', 'git+ssh://[email protected]/acme/connected.git'])(
'installs %s through real Git and pnpm with private repository SSH fallback', async (spec) => {
const pnpm = fileURLToPath(new URL('../../../../apps/desktop/node_modules/pnpm/bin/pnpm.mjs', import.meta.url))
const env = { GIT_CONFIG_GLOBAL: '', GIT_CONFIG_NOSYSTEM: '1' }
const { manager, dir, connection } = await fixture('startup', false, undefined, {}, {
command: process.execPath, args: ['--expose-internals', pnpm], env,
})
connection.mockRestore()
const repository = join(dir, 'repository')
mkdirSync(repository)
env.GIT_CONFIG_GLOBAL = join(dir, 'git.config')
writeFileSync(env.GIT_CONFIG_GLOBAL, '')
const git = (args: string[]) => execa('git', args, { cwd: repository, env })
await git(['init', '--initial-branch=main'])
const name = '@test/github-connected'
writeFileSync(join(repository, 'package.json'), JSON.stringify({ name, version: '1.0.0', dsh: { bundle: { patch: './cordis.patch.yml' } } }))
writeFileSync(join(repository, 'cordis.patch.yml'), '[]\n')
await git(['add', '.'])
await git(['-c', 'user.name=Fixture', '-c', '[email protected]', '-c', 'commit.gpgsign=false', 'commit', '-m', 'fixture bundle'])
const requests: string[] = []
const sockets = new Set<Socket>()
const proxy = createServer((_request, response) => {
response.writeHead(401, { 'WWW-Authenticate': 'Basic realm="fixture"' })
response.end()
})
proxy.on('connection', (socket) => {
sockets.add(socket)
socket.once('close', () => { sockets.delete(socket) })
})
proxy.on('connect', (request, socket) => {
requests.push(request.url ?? '')
socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n')
})
onTestFinished(async () => {
for (const socket of sockets) socket.destroy()
await new Promise<void>((resolve, reject) => { proxy.close((error) => { if (error) reject(error); else resolve() }) })
})
proxy.listen(0, '127.0.0.1')
await once(proxy, 'listening')
const address = proxy.address()
if (address === null || typeof address === 'string') throw new Error('proxy did not bind a TCP port')
const proxyUrl = `http://127.0.0.1:${String(address.port)}`
writeFileSync(env.GIT_CONFIG_GLOBAL, `[url "${pathToFileURL(repository).href}"]\n insteadOf = ssh://[email protected]/acme/connected.git\n insteadOf = [email protected]:acme/connected.git\n[url "${proxyUrl}/"]\n insteadOf = https://github.com/\n[http]\n proxy =\n`)
// pnpm probes HTTPS with Node before falling back to SSH, even for an SSH spec.
// Route that HTTP request to our proxy too; Git's URL rewrite alone cannot isolate it.
const manifest = readProfileManifest('test', dir)
delete manifest.dependencies
writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest))
writeFileSync(join(dir, '.npmrc'), `store-dir=${join(dir, 'store').replaceAll('\\', '/')}\nhttps-proxy=${proxyUrl}\nproxy=${proxyUrl}\nnoproxy=\n`)
const result = await manager.installBundle(spec, { enabled: false })
expect(result.error).toBeUndefined()
expect(result).toMatchObject({ changed: true, bundle: name, packageResult: { exitCode: 0 } })
expect(readProfileManifest('test', dir).dependencies).toHaveProperty(name)
expect(requests).toContain('github.com:443')
},
)
it.each(['missing package', 'invalid manifest', 'not a bundle', 'missing patch', 'invalid patch'])(
'boots with %s, lists its error, and permits deselection without enabling the broken bundle', async (failure) => {
const { manager, dir, ctx } = await fixture('live', false, undefined, {}, undefined, (dir) => {
@@ -11,7 +11,7 @@
background: var(--dsw-alias-fill-tsp-secondary);
font-size: 12px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
color: var(--dsw-alias-label-tertiary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -21,3 +21,12 @@
flex: none;
opacity: 0.7;
}
/* First sacrifice when the title row (the enclosing inline-size container)
runs out of width: the preset is passive chrome, so it yields before any
interactive header action loses its label. */
@container (max-width: 540px) {
.label {
display: none;
}
}
@@ -71,7 +71,7 @@ export const en: Record<AgentPresetSettingsKey, string> = {
creatorDraft: 'Let the agent help me create a preset',
showPicker: 'Choose a mode for new tasks',
showPickerBeta: 'Beta',
showPickerBeta: 'Experimental',
showPickerDescription:
'When enabled, each new task can choose a mode and the default is set here. When disabled, new tasks use the application default preset. Existing tasks are unaffected.',
enablePickerToSetDefault: 'Turn on mode selection for new tasks to choose a default',
@@ -112,7 +112,7 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
creatorDraft: '让 Agent 帮我创建预设模式',
showPicker: '新任务可选择模式',
showPickerBeta: 'Beta',
showPickerBeta: '实验性',
showPickerDescription: '开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。',
enablePickerToSetDefault: '请先开启新任务模式选择,再设置默认值',
enablePickerToCreate: '请先开启新任务模式选择,再启动创造模式',
@@ -285,7 +285,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
{editing?.id === row.id
? (
<>
<Tooltip label={t('queue.save')} side="bottom" delayMs={500}>
<Tooltip portal label={t('queue.save')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
@@ -296,7 +296,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
<IconCheckOutlineRegular size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
<Tooltip portal label={t('queue.cancelEdit')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
@@ -311,7 +311,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
)
: (
<>
<Tooltip label={t('queue.edit')} side="bottom" delayMs={500} disabled={text === null}>
<Tooltip portal label={t('queue.edit')} side="bottom" delayMs={500} disabled={text === null}>
<button
type="button"
className={css.action}
@@ -327,7 +327,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
<IconEditOutlineRegular size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.remove')} side="bottom" delayMs={500}>
<Tooltip portal label={t('queue.remove')} side="bottom" delayMs={500}>
<button
type="button"
className={css.action}
@@ -344,7 +344,7 @@ export function QueueDock({ useSession, useProjection, updateQueue, notify, load
<IconTrashOutlineRegular size={14} />
</button>
</Tooltip>
<Tooltip label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
<Tooltip portal label={t('queue.steer')} side="bottom" delayMs={500} disabled={!running}>
<button
type="button"
className={css.action}
@@ -63,6 +63,12 @@
.titleRow {
grid-column: 2;
/* Anonymous inline-size container (CSS modules hash container-names per
module, so occupants match the nearest container): header occupants such
as the preset label and slot actions shed their text at row-width
breakpoints. The minmax(0, 1fr) column sizes the row, so inline-size
containment cannot collapse it. */
container-type: inline-size;
display: flex;
align-items: center;
gap: 0;
@@ -364,6 +364,22 @@ describe('QueueDock', () => {
expect(view.queryByText('second')).toBeNull()
})
it('portals a row action tooltip out of the panel, where the input card cannot cover it', () => {
vi.useFakeTimers()
try {
const single = snapshotWith([row('i-tip', 'queued draft')])
const source = liveSession(single)
const view = render(<QueueDock {...kitFor(single)} useSession={source.useSession} useProjection={source.useProjection} />)
fireEvent.mouseEnter(view.getByLabelText('删除排队消息'))
act(() => { vi.advanceTimersByTime(500) })
const tooltip = view.getByRole('tooltip')
expect(tooltip.textContent).toBe('删除排队消息')
expect(tooltip.parentElement).toBe(document.body)
} finally {
vi.useRealTimers()
}
})
it('keeps an in-flight row action visible when another item arrives', async () => {
const single = snapshotWith([row('i-remove', 'remove me')])
const source = liveSession(single)
@@ -103,7 +103,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
/>
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
<div className={css.actions}>
<Tooltip label={t('action.save')} side="bottom" delayMs={500}>
<Tooltip portal label={t('action.save')} side="bottom" delayMs={500}>
<button
type="button"
className={css.iconBtn}
@@ -114,7 +114,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
<IconCheckOutlineRegular size={14} />
</button>
</Tooltip>
<Tooltip label={t('action.cancel')} side="bottom" delayMs={500}>
<Tooltip portal label={t('action.cancel')} side="bottom" delayMs={500}>
<button
type="button"
className={css.iconBtn}
@@ -144,20 +144,20 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
{actionError !== null && <span className={css.error} role="alert">{actionError}</span>}
<div className={css.actions}>
{goal.phase === 'active' && activation === 'armed' && (
<Tooltip label={t('action.pause')} side="bottom" delayMs={500}>
<Tooltip portal label={t('action.pause')} side="bottom" delayMs={500}>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onPause) }} aria-label={t('action.pause')}>
<IconPauseOutlineRegular size={14} />
</button>
</Tooltip>
)}
{showResume && (
<Tooltip label={t('action.resume')} side="bottom" delayMs={500}>
<Tooltip portal label={t('action.resume')} side="bottom" delayMs={500}>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void runAction(onResume) }} aria-label={t('action.resume')}>
<IconPlayOutlineRegular size={14} />
</button>
</Tooltip>
)}
<Tooltip label={t('action.edit')} side="bottom" delayMs={500}>
<Tooltip portal label={t('action.edit')} side="bottom" delayMs={500}>
<button
type="button"
className={css.iconBtn}
@@ -168,7 +168,7 @@ export function GoalBar({ goal, activation, onEdit, onPause, onResume, onClear,
<IconEditOutlineRegular size={14} />
</button>
</Tooltip>
<Tooltip label={t('action.clear')} side="bottom" delayMs={500}>
<Tooltip portal label={t('action.clear')} side="bottom" delayMs={500}>
<button type="button" className={css.iconBtn} disabled={pending} onClick={() => { void handleClear(goal.id) }} aria-label={t('action.clear')}>
<IconTrashOutlineRegular size={14} />
</button>
@@ -151,6 +151,20 @@ describe('GoalBar', () => {
expect(actions.onResume).toHaveBeenCalledTimes(1)
})
it('portals an action tooltip out of the strip, where the input card cannot cover it', () => {
vi.useFakeTimers()
try {
render(<GoalBar goal={makeGoal()} activation="armed" {...makeActions()} t={t} />)
fireEvent.mouseEnter(screen.getByRole('button', { name: '暂停目标' }))
act(() => { vi.advanceTimersByTime(500) })
const tooltip = screen.getByRole('tooltip')
expect(tooltip.textContent).toBe('暂停目标')
expect(tooltip.parentElement).toBe(document.body)
} finally {
vi.useRealTimers()
}
})
it('a new goal identity drops the edit form (no stale draft over the new goal)', () => {
const actions = makeActions()
const { rerender } = render(<GoalBar goal={makeGoal()} {...actions} t={t} />)
+2 -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/client/ui-jobs/README.md
README.md: 6f2b856a970cc869676bbda57d749cf4fb7ceba0
README.zh.md: b99723e841cfb3d6c52ba0ead7e1ab4dd1952e51
README.md: 0c530b802b502ea9db3135a711ed5b643584a79e
README.zh.md: 86ac2890111c098d01d5a7bc2997f99e1aa4065f
+1 -1
View File
@@ -45,7 +45,7 @@ Expanding an observable row opens that job's output observation stream from `ctx
<details>
<summary>Implementation internals — click to expand</summary>
One slot entry in the header actions band (after the preset label, before the subagent catalog) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
One slot entry in the header actions band (after the preset label) renders the trigger and popover; the popover fits itself to the viewport by measuring its anchor. All data arrives through `ctx.jobs` — the component holds no transport state. The roster follows the mount: one `useEffect` keeps the session's `job.list` stream open while the control lives. Observation follows visibility: another `useEffect` opens the stream for the expanded row's job and closes it on collapse, unmount, or popover close.
| File | Role |
|---|---|
+1 -1
View File
@@ -45,7 +45,7 @@ kind: "package-reference"
<details>
<summary>实现内幕——点击展开</summary>
头部操作带里的一个 slot 条目(preset 标签之后、subagent 目录之前)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
头部操作带里的一个 slot 条目(preset 标签之后)渲染触发器与弹出层;弹出层通过测量锚点把自己收进视口。所有数据经 `ctx.jobs` 到达——组件不持有任何传输状态。名册跟随挂载:一个 `useEffect` 在控件存活期间保持会话的 `job.list` 流打开。观测跟随可见性:另一个 `useEffect` 为展开行的 job 打开流,并在收起、卸载或弹出层关闭时关闭它。
| 文件 | 角色 |
|---|---|
+1 -2
View File
@@ -37,8 +37,7 @@ export function apply(ctx: ClientContext): void {
() => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'job-list',
// Between the preset label and the subagent catalog (order 30): running
// work reads before the session lineage.
// Background work follows the preset label in the header actions band.
order: 20,
locale: NS,
inject: (): JobListInjected => ({
@@ -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-plugin-manager/README.md
README.md: d89e56a1115d077865c49150ecbf4991a524cfe9
README.zh.md: 50699e47490425f34eb295bec057ea970b84b7e5
README.md: ae6bae12c8fead7ba4a4f3529a16b8a5225f9e3b
README.zh.md: 4ee4871f69dbcf4e14cdaa931c041631780a6e38
+4 -4
View File
@@ -25,7 +25,7 @@ Use the **Plugins** entry in the Web sidebar to manage the profile's installed b
<a id="use-this-package"></a>
## Use this package
Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Beta** where the feature is one — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section.
Select **Plugins** in the sidebar. The page reads the inventory and the bundles through `api-remotes` when first opened; a Host without a managed profile shows the page as unavailable. **Official** comes first and lists the bundles the installation ships for switching on — off until switched on, without an uninstall, and tagged **Experimental** for experimental features — followed by the official plugins that registered a configuration page; **Installed** lists the bundles the profile holds. Cards are listed by name, so switching a bundle on or off does not move its card. A dependency without a bundle patch is not a plugin and is not listed unless the profile selects it, in which case it carries a problem tag. Global configuration remains in the Settings **Plugins** section.
Installed bundles and their plugin rows display their own title and description in the current UI language on cards and detail pages. Each field falls back from exported locale `meta` to the accessible `package.json` at that plugin address; the final title is the full package or module name, with no package description when both sources omit it. See [Plugin display metadata](../../../docs/cookbook/adding-a-package.md#plugin-display-metadata) for the author format. Bundle cards, details, and component rows display the image declared by their own `package.json.icon`; absent or undecodable images retain the default artwork. Installation previews still use registry or manifest information.
@@ -39,7 +39,7 @@ The Host sends GET requests to `https://registry.npmjs.org/-/ping` and `https://
During preparation and download, **Cancel install** asks the Host to stop the run and waits for confirmation. Loading the bundle cannot be cancelled. Closing with ×, Escape, or the backdrop hides the dialog immediately and requests cancellation when possible. **View installation** reopens the same task with its output; another installation cannot start while its outcome is pending or unconfirmed. Confirmed cancellation returns to the spec and shows a toast; the manifest and lockfile are restored, while downloaded files can remain. A lost installation response triggers a request to recover the result; **Check installation status** and reconnect retry that request. If the Host has no active request, **Installation result unavailable** allows returning to editing after checking the plugin list. A cancellation sent before acceptance waits and retries automatically; a failed cancellation can be retried manually. Hidden tasks report their outcome through toasts without reopening the dialog.
When the Host attributes a network failure or timeout to a GitHub address and offers npmmirror, the dialog says **Cannot access GitHub** and offers **Use mainland China mirror** or **Cancel**. Choosing the mirror returns to an empty package-name field and remembers the selected registry without starting another installation. Other failures retain their existing diagnostics and actions. The mirror supplies registry packages and dependencies, not the GitHub repository itself.
When the Host attributes a network failure or timeout to a GitHub address and offers npmmirror, the dialog says **Cannot access GitHub**, or **GitHub connection timed out** for a timeout, and offers **Use mainland China mirror** or **Cancel**. Choosing the mirror returns to an empty package-name field and remembers the selected registry without starting another installation. Other failures retain their existing diagnostics and actions. The mirror supplies registry packages and dependencies, not the GitHub repository itself.
### Switching a bundle
@@ -65,7 +65,7 @@ The bundle's patch must declare the row under that id, and the registration exis
### Detail page extension points
A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, beta, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`.
A plugin with something to say about a bundle, a row, or an official plugin it does not own contributes to that object's page through three list slots the page declares: `plugins.detail.actions` for a control at the head of the page, before the page's own switch and uninstall; `plugins.detail.badge` for a tag beside the title, after the version, experimental, and problem tags; and `plugins.detail.section` for a section under the page's own content — after the rows on a bundle's page, after the configuration on a row's or an official plugin's page. Every entry is rendered with the page's `subject`: `{ kind: 'bundle', pkg }`, `{ kind: 'row', pkg, row }`, or `{ kind: 'item', id }`, where `pkg` and `row` carry the name, version, installed and enabled facts, and the row list a contribution decides on. An entry renders null for a subject it has nothing for and draws its own section chrome; the page orders entries by `order`.
```tsx ignore-check
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
@@ -101,7 +101,7 @@ The browser plugin registers the `plugins` sidebar entry and its `main` panel th
Custom item pages use the Host entry id as their registration id; row pages use the bundle package and row id. The page owner supplies `form.state` and `form.mutate(operations, expectedRevision)` when the entry exposes editable Config fields. A custom page owns its draft and validation display, and may reuse `ConfigField` from ui-primitives. Bundle-wide pages can contain several entries and have no single form.
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Beta marker.
The page's `main` registration declares `plugins.item`, `plugins.bundle.config`, and `plugins.row.config` as its children, so the slots exist while the page does and a registrant's `ctx.slots.inject` waits for them. `configLedgerSource` projects the three ledgers into one observable — the official items in ledger order with their labels resolved in the active locale, and the bundle and row keys — cached until a ledger or the locale moves; the page binds it as `useConfigLedger` beside the store and never names a configurable plugin itself. Which page is open is page-local state: the cards, a bundle, an official plugin, or a row of a bundle. A registration lives with the browser half that made it. `dsh-client-modules` attaches a package's browser half to the Loader row whose specifier is the bare package name, so every page a bundle registers, for itself or for any of its rows, goes away when that row is switched off; a sub-plugin whose page must outlive the other rows ships as its own package. Official packages whose names begin with `@deepseek-ai/dsh-experimental-` display the Experimental marker.
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` offers bundle-owned guidance after explicit enablement from the list, with callbacks to dismiss it or open the bundle details. It does not appear merely because an enabled bundle was listed.
@@ -25,7 +25,7 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于 beta 功能的带 **Beta** 标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。
在侧栏选择**插件**。页面首次打开时通过 `api-remotes` 读取清单与组合包;没有受管 profile 的 Host 上页面显示为不可用。**官方**排在前面,列出安装随附、供开启的组合包——开启前保持关闭、没有卸载、属于实验性功能的带**实验性**标签——其后是注册了配置页的官方插件;**已安装**列出 profile 持有的组合包。卡片按名称排序,启停组合包不会挪动它的卡片。没有组合包 patch 的依赖不是插件,除非 profile 选中了它才会带异常标签列出。全局配置仍在设置的**插件**分区中编辑。
已安装的组合包及其插件行在卡片和详情页中,按当前界面语言显示各自的标题与描述。每个字段先读取导出的 locale `meta`,缺失时回退到该插件地址下可访问的 `package.json`;标题最终使用完整包名或模块名,两处都没有描述时不提供包描述。作者格式见[插件展示元信息](../../../docs/cookbook/adding-a-package.zh.md#plugin-display-metadata)。组合包卡片、详情和组件行显示各自 `package.json.icon` 声明的图片;未声明或无法解码时保留默认插画。安装预览仍使用注册表或 manifest 信息。
@@ -39,7 +39,7 @@ Host 通过普通 fetch 代理向 `https://registry.npmjs.org/-/ping` 和 `https
准备和下载期间,**取消安装**会请求 Host 停止运行并等待确认。加载组合包的阶段不可取消。点击 ×、按 Escape 或点击遮罩会立即隐藏对话框,并在可以取消时请求取消。**查看安装任务**会重新打开同一任务并保留输出;结果待定或尚未确认时不能发起另一项安装。确认取消后回到 spec 输入界面并显示 toast;manifest 与 lockfile 已恢复,已下载文件可能保留。安装响应丢失后会请求恢复结果;**核对安装状态**和重连会重试该请求。Host 已无活动请求时,**未能获取安装结果**允许检查插件列表后返回编辑。早于接收确认的取消请求会等待并自动重试;取消失败可手动重试。隐藏的任务通过 toast 通知结果,不会重新弹出对话框。
Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时,对话框显示**无法访问 GitHub**,提供**改用国内镜像**和**取消**。选择镜像后回到空的包名输入框,记住所选安装源,不自动开始下一次安装。其他失败保留原有诊断和操作。镜像提供注册表中的包及依赖,不代替 GitHub 仓库下载。
Host 将网络失败或超时归因于 GitHub 地址,且提供 npmmirror 时,对话框显示**无法访问 GitHub**,超时时显示**连接 GitHub 超时**,提供**改用国内镜像**和**取消**。选择镜像后回到空的包名输入框,记住所选安装源,不自动开始下一次安装。其他失败保留原有诊断和操作。镜像提供注册表中的包及依赖,不代替 GitHub 仓库下载。
### 切换一个组合包
@@ -65,7 +65,7 @@ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
### 详情页扩展点
对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、Beta 和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。
对某个不属于自己的组合包、行或官方插件有话要说的插件,通过本页声明的三个 list slot 向该对象的页面贡献内容:`plugins.detail.actions` 在页头放一个控件,位于页面自己的开关和卸载之前;`plugins.detail.badge` 在标题旁放一个标签,位于版本、实验性和异常标签之后;`plugins.detail.section` 在页面自身内容之下放一个区块——组合包页在组件列表之后,行页和官方插件页在配置之后。每个条目都以页面的 `subject` 渲染:`{ kind: 'bundle', pkg }`、`{ kind: 'row', pkg, row }` 或 `{ kind: 'item', id }`,其中 `pkg` 与 `row` 携带包名、版本、是否已安装、是否启用以及行列表这些供贡献者判断的事实。条目对无话可说的 subject 返回 null,自绘区块外观;页面按 `order` 排列条目。
```tsx ignore-check
ctx.slots.inject('plugins.detail.section', () => ctx.slots.register({
@@ -101,7 +101,7 @@ Host 入口通过生成的 Remote 接口暴露 `pluginRegistryProbe.fastest()`
自定义条目页以 Host 条目 id 作为注册 id;行页面使用 bundle 包名和行 id。当条目提供可编辑 Config 字段时,页面宿主传入 `form.state` 和 `form.mutate(operations, expectedRevision)`。自定义页面负责草稿和校验提示,并可复用 ui-primitives 的 `ConfigField`。整个 bundle 的页面可以包含多个条目,因此没有单一表单。
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示 Beta 标记。
页面的 `main` 注册把 `plugins.item`、`plugins.bundle.config` 与 `plugins.row.config` 声明为子 slot,因此它们与页面同生,注册方的 `ctx.slots.inject` 会等到它们出现。`configLedgerSource` 把三份账本投影成一个可观察对象——按账本顺序排列、标签按当前语言解析的官方条目,以及组合包与行的键——在账本或语言变化前保持缓存;页面把它作为 `useConfigLedger` 绑在 store 旁边,自身从不点名任何可配置插件。打开的是哪一页是页面本地状态:卡片、某个组合包、某个官方插件,或组合包的某一行。注册与做出它的浏览器半侧同生共死。`dsh-client-modules` 只把一个包的浏览器半侧挂在说明符恰为包名的那一行 Loader 行上,所以组合包为自己或任一行注册的页面,都会在那一行被关闭时一起消失;需要在其他行关闭时仍保留页面的子插件,应作为独立的包发布。 名称以 `@deepseek-ai/dsh-experimental-` 开头的官方包显示实验性标记。
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户从列表显式启用后提供 Bundle 自有引导,并传入关闭引导和打开详情的回调。仅列出已启用的 Bundle 不会触发引导。
@@ -785,7 +785,7 @@ function InstallDialog({
<Modal
open={install.open}
onClose={onClose}
title={t('installGithubFailedTitle')}
title={t(install.failure?.kind === 'timeout' ? 'installGithubTimeoutTitle' : 'installGithubFailedTitle')}
closeLabel={t('close')}
description={t('installGithubFailedDescription')}
footer={(
@@ -17,7 +17,7 @@ export const zh = {
bundlesTitle: '已安装',
officialTitle: '官方',
statusProblem: '异常',
statusBeta: 'Beta',
statusBeta: '实验性',
reasonLabel: '原因',
metadataError: '包元信息错误:{error}',
versionTag: 'v{version}',
@@ -88,6 +88,7 @@ export const zh = {
installedTitle: '已安装',
installFailedTitle: '插件安装失败',
installGithubFailedTitle: '无法访问 GitHub',
installGithubTimeoutTitle: '连接 GitHub 超时',
installGithubFailedDescription: '请尝试其他安装来源。',
installUseGithubMirror: '改用国内镜像',
installPackageLabel: '插件包名',
@@ -205,7 +206,7 @@ export const en = {
bundlesTitle: 'Installed',
officialTitle: 'Official',
statusProblem: 'Problem',
statusBeta: 'Beta',
statusBeta: 'Experimental',
reasonLabel: 'Reason',
metadataError: 'Package metadata error: {error}',
versionTag: 'v{version}',
@@ -276,6 +277,7 @@ export const en = {
installedTitle: 'Installed',
installFailedTitle: 'The plugin could not be installed',
installGithubFailedTitle: 'Cannot access GitHub',
installGithubTimeoutTitle: 'GitHub connection timed out',
installGithubFailedDescription: 'Try another installation source.',
installUseGithubMirror: 'Use mainland China mirror',
installPackageLabel: 'Plugin package name',
@@ -1057,16 +1057,16 @@ describe('PluginManagerPage', () => {
expect(screen.getByText(en.installSubjectTarball)).toBeTruthy()
})
it('shows GitHub recovery only after a connection failure and returns to package input', () => {
it.each(['network', 'timeout'] as const)('shows GitHub recovery only after a %s failure and returns to package input', (kind) => {
const subject = { spec: 'github:a/b', status: 'accepted', kind: 'git', bundle: null, registry: null, host: 'github.com' } as const
const failed: InstallState = {
...IDLE_INSTALL, open: true, spec: subject.spec, registries: REGISTRIES, phase: 'failed', subject,
failure: { reason: 'Could not resolve host: github.com', kind: 'network', failedAt: 'spec-host' },
failure: { reason: 'GitHub connection failed', kind, failedAt: 'spec-host' },
}
const { actions, set, setLanguage } = renderTab({ install: { ...failed, phase: 'idle', failure: null } })
expect(screen.queryByText(en.installGithubFailedTitle)).toBeNull()
set({ install: failed })
const dialog = screen.getByRole('dialog', { name: en.installGithubFailedTitle })
const dialog = screen.getByRole('dialog', { name: kind === 'timeout' ? en.installGithubTimeoutTitle : en.installGithubFailedTitle })
expect(within(dialog).getByText(en.installGithubFailedDescription)).toBeTruthy()
expect(screen.queryByRole('button', { name: en.installRetry })).toBeNull()
fireEvent.click(within(dialog).getByRole('button', { name: en.installUseGithubMirror }))
@@ -1075,7 +1075,7 @@ describe('PluginManagerPage', () => {
fireEvent.click(within(dialog).getByRole('button', { name: en.cancel }))
expect(actions.closeInstall).toHaveBeenCalledOnce()
setLanguage(zh)
expect(screen.getByRole('dialog', { name: '无法访问 GitHub' })).toBeTruthy()
expect(screen.getByRole('dialog', { name: kind === 'timeout' ? '连接 GitHub 超时' : '无法访问 GitHub' })).toBeTruthy()
expect(screen.getByText('请尝试其他安装来源。')).toBeTruthy()
set({ install: { ...IDLE_INSTALL, open: true, mirrorRecovery: true, registries: REGISTRIES, registry: { kind: 'offered', registry: MIRROR } } })
const form = within(screen.getByRole('dialog', { name: zh.installTitle }))
@@ -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-primitives/README.md
README.md: 73b3da1aa827a02e978516e37d618b9569253dd7
README.zh.md: 080852d90b017124acd5eefecb49f12452626a0c
README.md: 7d474aeb0d63991879825188f4a702bbad40f343
README.zh.md: a0423daa88497b21238b2747395b163bf0309063
+1 -1
View File
@@ -52,7 +52,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `Modal` | Centered dialog over a page mask. |
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
| `OnboardingSurface` | First-run stage that holds the application root inert. |
| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers. |
| `Tooltip` | Hover text anchored to a cloned child; optional `portal` rendering escapes clipping containers and ancestor stacking contexts that cap the bubble's z-index. |
| `HoverCard` | Hover preview the pointer can rest on and select from; optional copy button. |
| `Toast` | Transient top-center banner held for the owner's `holdMs`. |
| `SettingsForm`, `SettingsValueField`, `SettingsSecretField` | The frame and the controls of a plugin's settings page: the frame takes its copy as `labels`, saves only on its button, and discards on unmount; a value field shows staged text with the overridden badge and reset; a secret field starts blank and reports only whether a value is configured. |
+1 -1
View File
@@ -52,7 +52,7 @@ kind: "package-library"
| `Modal` | 页面遮罩之上的居中对话框。 |
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 |
| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪。 |
| `Tooltip` | 锚定在克隆子元素上的悬停文本;可通过 `portal` 渲染到外层,避免被容器裁剪,或受祖先层叠上下文限制其 z-index。 |
| `HoverCard` | 指针可停留、可选中的悬停预览;可选带复制按钮。 |
| `Toast` | 顶部居中的瞬时横幅,保持时长由所有者的 `holdMs` 决定。 |
| `SettingsForm`、`SettingsValueField`、`SettingsSecretField` | 插件设置页的框架与控件:框架以 `labels` 接收文案,只在按钮点击时保存,卸载即丢弃;值字段显示暂存文本以及已覆盖标签和重置;密文字段每次为空,只报告是否已配置。 |
+11 -8
View File
@@ -1,4 +1,4 @@
/** Anchor-preserving tooltips with optional body portals for clipping containers. */
/** Anchor-preserving tooltips; an optional body portal escapes clipping containers and stacking contexts that cap the bubble's z-index. */
import { cloneElement, createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import type { FocusEventHandler, MouseEventHandler, MutableRefObject, ReactElement, Ref } from 'react'
@@ -46,8 +46,11 @@ if (typeof window !== 'undefined') {
* @param props.align - horizontal anchor-edge alignment for 'bottom'/'top' bubbles: 'end' pins
* the bubble's right edge to the anchor's (for anchors beside other hover surfaces the centered
* bubble would overlap); default 'center'. Ignored for side 'right'.
* @param props.portal - render the bubble under document.body to escape containing blocks and clipping ancestors.
* @param props.portal - render the bubble under document.body, so an ancestor's clipping or its
* stacking context (which confines the bubble's z-index to that context) cannot hide it.
* @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate.
* @param props.gap - anchor-to-bubble distance in pixels for 'bottom'/'top' bubbles (default 8);
* ignored for side 'right'.
* @param props.disabled - suppress the bubble while true; the anchor renders identically so
* toggling never remounts it (which would cut its CSS transitions).
* @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default
@@ -58,7 +61,7 @@ if (typeof window !== 'undefined') {
* anchor dismisses the bubble until the next trigger, and focus arriving after a pointer
* interaction (a closing menu refocusing its trigger) never raises it.
*/
export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0, gap = 8, disabled = false, portal = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; align?: 'center' | 'end'; delayMs?: number; gap?: number; disabled?: boolean; portal?: boolean; maxWidth?: number; children: ReactElement<AnchorProps> }) {
const anchor = useRef<HTMLElement | null>(null)
// React 18 keeps the element's ref outside props; forward it so wrapping an
// anchor in Tooltip never silently severs the owner's ref.
@@ -79,7 +82,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
? 0
: side === 'right'
? pos.top + (pos.bottom - pos.top) / 2
: side === 'top' ? pos.top - 8 : pos.bottom + 8
: side === 'top' ? pos.top - gap : pos.bottom + gap
const showTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Hover and focus are independent triggers: the bubble hides only after
// BOTH clear (hovering away from a focused anchor must not drop it).
@@ -107,13 +110,13 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
const { inlineSize: width, blockSize: height } = size
const offset = side === 'right' ? 0 : align === 'end' ? width : width / 2
const left = Math.max(edgeMargin, Math.min(pos.x - offset, window.innerWidth - edgeMargin - width))
const fitsBelow = pos.bottom + 8 + height <= window.innerHeight - edgeMargin
const fitsAbove = pos.top - 8 - height >= edgeMargin
const fitsBelow = pos.bottom + gap + height <= window.innerHeight - edgeMargin
const fitsAbove = pos.top - gap - height >= edgeMargin
if (placement === 'bottom' && !fitsBelow && fitsAbove) placement = 'top'
else if (placement === 'top' && !fitsAbove && fitsBelow) placement = 'bottom'
el.style.left = `${left + offset}px`
el.style.top = `${placement === 'right' ? (pos.top + pos.bottom) / 2
: placement === 'top' ? pos.top - 8 : pos.bottom + 8}px`
: placement === 'top' ? pos.top - gap : pos.bottom + gap}px`
el.dataset.side = placement
el.style.visibility = 'visible'
}
@@ -127,7 +130,7 @@ export function Tooltip({ label, side = 'right', align = 'center', delayMs = 0,
observer.disconnect()
window.removeEventListener('resize', fit)
}
}, [align, pos, side, suppressed, visible])
}, [align, gap, pos, side, suppressed, visible])
useEffect(() => {
announce(visible)
return () => { announce(false) }
@@ -640,8 +640,8 @@ export const IconWarningTriangleOutlineMedium = (props: IconProps) => (
const IconUserOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M8 8.5C9.65685 8.5 11 7.15685 11 5.5C11 3.84315 9.65685 2.5 8 2.5C6.34315 2.5 5 3.84315 5 5.5C5 7.15685 6.34315 8.5 8 8.5Z" stroke="currentColor" />
<path d="M1.5 14.5C1.5 11.25 4.25 10 8 10C11.75 10 14.5 11.25 14.5 14.5" stroke="currentColor" />
<path d="M8 8.25C9.51878 8.25 10.75 7.01878 10.75 5.5C10.75 3.98122 9.51878 2.75 8 2.75C6.48122 2.75 5.25 3.98122 5.25 5.5C5.25 7.01878 6.48122 8.25 8 8.25Z" stroke="currentColor" />
<path d="M2.5 14.5C2.5 11.5 5.25 10.25 8 10.25C10.75 10.25 13.5 11.5 13.5 14.5" stroke="currentColor" />
</svg>
)
@@ -655,6 +655,25 @@ export const IconUserOutlineMedium = (props: IconProps) => (
<IconUserOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconUsersOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M6 8.25C7.51878 8.25 8.75 7.01878 8.75 5.5C8.75 3.98122 7.51878 2.75 6 2.75C4.48122 2.75 3.25 3.98122 3.25 5.5C3.25 7.01878 4.48122 8.25 6 8.25Z" stroke="currentColor" />
<path d="M1 14.5C1 11.5 3.5 10.25 6 10.25C8.5 10.25 11 11.5 11 14.5" stroke="currentColor" />
<path d="M10.5 2.9C11.65 3.35 12.45 4.35 12.45 5.5C12.45 6.65 11.65 7.65 10.5 8.1" stroke="currentColor" />
<path d="M12.4 10.6C13.9 11.3 15 12.6 15 14.5" stroke="currentColor" />
</svg>
)
/** Regular one-pixel IconUsersOutline artwork: a front person with a second person behind. */
export const IconUsersOutlineRegular = (props: IconProps) => (
<IconUsersOutlineArtwork {...props} strokeWidth={ICON_REGULAR_STROKE} />
)
/** Medium IconUsersOutline artwork with a 1.3px stroke. */
export const IconUsersOutlineMedium = (props: IconProps) => (
<IconUsersOutlineArtwork {...props} strokeWidth={ICON_MEDIUM_STROKE} />
)
const IconPaperPlaneOutlineArtwork = ({ size = 14, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M4.74024 9.11029L1.82882 7.79865C1.75022 7.76323 1.75026 7.65161 1.82889 7.61626L12.9665 2.60943C13.0354 2.57846 13.1125 2.63213 13.1073 2.70749L12.3914 13.1388C12.3864 13.2117 12.3073 13.2548 12.2433 13.2194L6.12677 9.83657" stroke="currentColor" />
@@ -17,8 +17,8 @@ const icons = Object.fromEntries(
const iconNames = Object.keys(icons)
describe('product icon set', () => {
it('exports regular and medium variants for all 92 public glyphs', () => {
expect(iconNames.length).toBe(184)
it('exports regular and medium variants for all 93 public glyphs', () => {
expect(iconNames.length).toBe(186)
expect(iconNames.some(name => /\d+$/.test(name))).toBe(false)
const regular = iconNames.filter(name => name.endsWith('Regular')).map(name => name.slice(0, -'Regular'.length))
const medium = iconNames.filter(name => name.endsWith('Medium')).map(name => name.slice(0, -'Medium'.length))
@@ -309,6 +309,23 @@ describe('Tooltip', () => {
}
})
it.each(['bottom', 'top'] as const)('uses a custom gap to position and flip a %s tooltip', (side) => {
const anchorBottom = side === 'bottom' ? window.innerHeight - 36 : 56
const spy = placed(anchorBottom - 20, anchorBottom, 20)
try {
const view = render(<Tooltip label="Gap" side={side} gap={4}><button type="button">anchor</button></Tooltip>)
fireEvent.mouseEnter(screen.getByText('anchor'))
const bubble = screen.getByRole('tooltip')
expect(bubble.getAttribute('data-side')).toBe(side)
expect(bubble.style.top).toBe(`${side === 'bottom' ? anchorBottom + 4 : anchorBottom - 24}px`)
view.rerender(<Tooltip label="Gap" side={side} gap={12}><button type="button">anchor</button></Tooltip>)
expect(bubble.getAttribute('data-side')).toBe(side === 'bottom' ? 'top' : 'bottom')
expect(bubble.style.top).toBe(`${side === 'bottom' ? anchorBottom - 32 : anchorBottom + 12}px`)
} finally {
spy.mockRestore()
}
})
it('keeps the requested side when neither side fits', () => {
// A bubble taller than the viewport has no home; oscillating between the
// two would be worse than honouring the request.
+2 -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/client/ui-subagent/README.md
README.md: b3dfb4a3aa56dbf240f4b76db410d45cc6ae5651
README.zh.md: a6ebd6da3c3caba2a3bd015da450b5a33ab79d67
README.md: 037c84c9c80d1d1d129f543b9f60d834ef2d4ce7
README.zh.md: e7c3fb052c0b42dd3c102aa504a733c3938438c0
+3 -1
View File
@@ -25,12 +25,14 @@ Use this package to browse every subagent conversation beneath a parent session,
<a id="use-this-package"></a>
## Use this package
The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders in the header actions band after the task list, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
The session header keeps the current session title as the lineage breadcrumb; when the session's direct catalog has entries or a read has failed, the descendant-count trigger renders at the start of the header actions band, with no breadcrumb separator. An absent catalog, an empty loading catalog, or a successfully loaded empty catalog hides the count trigger. The trigger opens that direct catalog, reports its total and running counts, and loads nested catalogs only when their rows expand. Select any depth to open that child's conversation with its exact `{parentSessionId, childSessionId, mode}` address, or use the row's trailing arrow to open the same address in the right Sidebar, preferring a separate pane when room permits.
This package registers the `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` resource and builtin Sidebar tab type. The resource retains the child `SessionReference` directly from its address without refreshing the parent catalog, and releases the reference when the tab record closes. The tab renders the shared `conversation.content` Factory through `sidebar.chat.conversation`, fixes the local View to Chat, and omits the main Conversation header and width controls.
### Browsing the tree
Hovering a trigger opens its catalog after 150ms; leaving both trigger and catalog closes it after 120ms. Clicking the descendant-count trigger pins its catalog until outside click or Escape from the trigger or tree. Breadcrumb-title clicks navigate to the corresponding conversation.
Rows display mode plus activity and an optional log-backed title; running uses the shared ongoing loader, an inactive child whose latest closed turn completed normally uses the shared success dot, and other inactive children use the shared idle dot. Every row reserves the same 14px status column, centering smaller dots so titles align with the loader state. The compact header trigger vertically centers its activity glyph and count with a 4px gap. The trailing column stacks total durable provider usage above active-turn duration. Keyboard navigation works with ArrowRight/ArrowLeft to expand and collapse branches and ArrowUp/ArrowDown, Home, End, and Escape to navigate or close the tree. An unlabeled one-shot row falls back to its session id. A row is a known leaf only after its own catalog loads empty.
### Continuing a conversation
+3 -1
View File
@@ -25,12 +25,14 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区、任务列表之后,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
会话页头把当前会话标题作为谱系面包屑;当会话的直接目录有子项或读取失败时,后代数量触发器渲染在页头操作区的最前方,不带任何面包屑分隔符。目录缺席、空目录加载中或成功加载为空时,均隐藏数量触发器。触发器打开该直接目录,报告总数与运行数,并且只在行展开时加载嵌套目录。选择任意深度,即可用该子会话的确切 `{parentSessionId, childSessionId, mode}` 地址打开其对话;也可以使用行尾箭头在右侧 Sidebar 打开同一地址,并在空间允许时优先使用独立分栏。
本包注册 `dsh-resource://subagentchat/session/<child>?parent=<parent>&mode=<mode>` 资源与 builtin Sidebar tab 类型。资源直接根据地址保留 child 的 `SessionReference`,不刷新 parent 目录,并在 tab 记录关闭时释放 reference。tab 通过 `sidebar.chat.conversation` 渲染共享 `conversation.content` Factory,把局部 View 固定为 Chat,并省略主 Conversation 的 Header 与宽度控制。
### 浏览目录
悬停触发器 150ms 后打开目录;指针离开触发器和目录后,经过 120ms 关闭。点击后代数量触发器会固定目录,直到点击外部,或在触发器或树内按 Escape 关闭。点击面包屑标题会导航至对应会话。
行显示 mode、活动状态与由日志支撑的可选 title;running 使用共享 ongoing loading,最近一个已结束轮次正常完成的 inactive child 使用共享 success 绿点,其他 inactive child 使用共享 idle 灰点。每行都为状态图标预留相同的 14px 列宽,并将较小的圆点居中,使 title 与 loading 状态对齐。紧凑的页头触发器会垂直居中活动图标与数量,并保留 4px 水平间距。尾随列在上行显示提供方的持久化 token 用量总计,在下行显示活跃轮次耗时。键盘导航:ArrowRight/ArrowLeft 展开和折叠分支;ArrowUp/ArrowDown、Home、End 与 Escape 用于导航或关闭树。没有 label 的 one-shot 行回退到其会话 id。只有一行自身的目录加载为空后,它才是已知叶子。
### 续接对话
@@ -66,7 +66,7 @@
.trigger:hover,
.trigger:focus-visible {
color: var(--dsw-alias-label-secondary);
color: var(--dsw-alias-label-primary);
}
.switcherTrigger:hover,
@@ -98,21 +98,41 @@
max-width: min(400px, calc(100vw - 32px));
max-height: min(560px, calc(100vh - 140px));
padding: 3px;
overflow: auto;
overflow: hidden;
border-radius: 16px;
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
--dsw-elevation-stroke-color: var(--dsw-alias-border-l1);
box-shadow: var(--dsw-elevation-prominent);
}
/* The menu skin lives on a pseudo-element: backdrop-filter on the menu itself
would turn it into the containing block for fixed descendants, so the
sidebar-button Tooltip bubbles would be re-anchored into the menu and
clipped by its hidden overflow. */
.menu::before {
content: '';
position: absolute;
z-index: -1;
inset: 0;
border-radius: 16px;
background: var(--dsw-specific-menu);
backdrop-filter: var(--dsw-menu-backdrop-filter);
}
.menuBody {
display: flex;
flex-direction: column;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.node {
position: relative;
min-width: 0;
}
.menu > .node {
.menuBody > .node {
margin-left: -2px;
}
@@ -7,7 +7,7 @@ import {
import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconRefreshOutlineRegular, StateDot,
IconChevronDownOutlineRegular, IconChevronRightOutlineRegular, IconRefreshOutlineRegular, StateDot, Tooltip,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
import { NS } from './locales.ts'
@@ -351,16 +351,17 @@ function CatalogRows({
</span>
)}
{!isCurrent && (
<button
type="button"
className={css.sidebarButton}
aria-label={t('open.sidebar', { label })}
title={t('open.sidebar', { label })}
onClick={openAside}
onKeyDown={(event) => { event.stopPropagation() }}
>
<IconChevronRightOutlineRegular />
</button>
<Tooltip label={t('open.sidebar')} side="bottom" align="end">
<button
type="button"
className={css.sidebarButton}
aria-label={t('open.sidebar.aria', { label })}
onClick={openAside}
onKeyDown={(event) => { event.stopPropagation() }}
>
<IconChevronRightOutlineRegular />
</button>
</Tooltip>
)}
</div>
</div>
@@ -468,6 +469,8 @@ function CatalogDropdown({
const menuRef = useRef<HTMLDivElement>(null)
const hoverOpenTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const hoverCloseTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
// A click-opened (pinned) menu ignores hover-out; only explicit dismissal closes it.
const pinnedRef = useRef(false)
const currentEntry = currentSessionId === undefined
? undefined
: catalog?.entries.find(entry => entry.id === currentSessionId)
@@ -506,6 +509,7 @@ function CatalogDropdown({
setMenuPosition(catalogMenuPosition(trigger))
}
else {
pinnedRef.current = false
setOpen(false)
setMenuPosition(undefined)
setExpanded(new Set())
@@ -526,6 +530,7 @@ function CatalogDropdown({
const scheduleHoverClose = (): void => {
cancelHoverOpen()
cancelHoverClose()
if (pinnedRef.current) return
hoverCloseTimer.current = setTimeout(() => {
hoverCloseTimer.current = undefined
changeOpen(false)
@@ -602,6 +607,7 @@ function CatalogDropdown({
cancelHoverOpen()
cancelHoverClose()
if (!open) return
pinnedRef.current = false
setOpen(false)
setExpanded(new Set())
}, [visible, open])
@@ -640,11 +646,11 @@ function CatalogDropdown({
className={`${css.root} ${variant === 'switcher' ? css.switcherRoot : ''}`}
ref={rootRef}
onKeyDown={navigate}
onMouseEnter={scheduleHoverOpen}
onMouseLeave={scheduleHoverClose}
>
<button
ref={triggerRef}
onMouseEnter={scheduleHoverOpen}
type="button"
className={variant === 'switcher'
? `${css.switcherTrigger} ${ancestorSwitcher ? css.ancestorSwitcherTrigger : ''}`
@@ -658,7 +664,12 @@ function CatalogDropdown({
{ count: runningCount > 0 ? runningCount : directCount },
)}
onClick={openTitle === undefined
? undefined
? () => {
cancelHoverOpen()
cancelHoverClose()
pinnedRef.current = true
if (!open) changeOpen(true)
}
: () => {
cancelHoverOpen()
if (open) changeOpen(false)
@@ -692,26 +703,26 @@ function CatalogDropdown({
ref={menuRef}
className={css.menu}
style={menuPosition}
role="tree"
aria-label={t('tree.aria')}
onMouseEnter={cancelHoverClose}
onMouseLeave={scheduleHoverClose}
>
<CatalogRows
parentSessionId={rootSessionId}
currentSessionId={currentSessionId}
catalog={presentedCatalog}
catalogs={catalogs}
summaries={summaries}
expanded={expanded}
level={1}
openChild={openChild}
openChildAside={openChildAside}
refreshProjection={refreshProjection}
toggleBranch={toggleBranch}
closeCatalog={() => { changeOpen(false) }}
t={t}
/>
<div className={css.menuBody} role="tree" aria-label={t('tree.aria')}>
<CatalogRows
parentSessionId={rootSessionId}
currentSessionId={currentSessionId}
catalog={presentedCatalog}
catalogs={catalogs}
summaries={summaries}
expanded={expanded}
level={1}
openChild={openChild}
openChildAside={openChildAside}
refreshProjection={refreshProjection}
toggleBranch={toggleBranch}
closeCatalog={() => { changeOpen(false) }}
t={t}
/>
</div>
</div>
), document.body)}
</div>
@@ -724,7 +735,7 @@ export type SubagentCatalogActionProps =
/**
* Session-header catalog action for root sessions: the descendant count and
* its dropdown, ordered after the task list. Child sessions render nothing
* its dropdown at the start of the header actions band. Child sessions render nothing
* here — their breadcrumb switcher in the lineage slot owns the same
* navigation.
* @param props - Session standard props plus the catalog actions and translator.
@@ -769,7 +780,7 @@ export function SubagentHeaderLineage({
})
const shared = { useSessions, useSessionStatus, openChild, openChildAside, refreshProjection, t }
// Root sessions carry no breadcrumb; their descendant count lives in the
// header actions band (SubagentCatalogAction), after the task list.
// header actions band (SubagentCatalogAction).
if (parentId === undefined) return null
return (
<>
@@ -84,9 +84,10 @@ export function apply(ctx: ClientContext): void {
() => ctx.slots.register({
name: 'conversation.session.header.actions',
id: 'subagent-catalog',
// After the task list: the preset label leads, running work follows,
// and delegation navigation closes the band.
order: 30,
// Leads the band, directly after the title crumbs: subagent lineage is
// the title's own continuation, ahead of Team navigation (-20) and the
// preset label (-10).
order: -30,
locale: NS,
inject: catalogActions,
}, SubagentCatalogAction),
@@ -37,7 +37,8 @@ export const zh = {
'count.running.other': '{count} 个子代理,正在运行',
'switcher.aria': '切换子代理:{title}',
'tree.aria': '子代理会话',
'open.sidebar': '在侧边栏打开 {label}',
'open.sidebar': '在侧边栏打开',
'open.sidebar.aria': '在侧边栏打开 {label}',
'sidebar.chat': '聊天',
'readonly.oneShot.title': '一次性子代理记录',
'readonly.title': '此子代理暂时只读',
@@ -79,7 +80,8 @@ export const en: Record<SubagentKey, string> = {
'count.running.other': '{count} subagents running',
'switcher.aria': 'Switch subagent: {title}',
'tree.aria': 'Subagent sessions',
'open.sidebar': 'Open {label} in sidebar',
'open.sidebar': 'Open in sidebar',
'open.sidebar.aria': 'Open {label} in sidebar',
'sidebar.chat': 'Chat',
'readonly.oneShot.title': 'One-shot subagent record',
'readonly.title': 'This subagent is read-only for now',
@@ -134,10 +134,10 @@ describe('apply', () => {
])
// The root-session catalog seat registers in the actions band with the
// same business face, ordered after the task list.
// same business face, leading the band directly after the title crumbs.
const actionEntry = ctx.slots.entries('conversation.session.header.actions')
.find(entry => entry.options.id === 'subagent-catalog')!
expect(actionEntry.options.order).toBe(30)
expect(actionEntry.options.order).toBe(-30)
expect(actionEntry.inject).toBe(catalogEntry.inject)
const composerEntry = ctx.slots.entries('conversation.composer')
@@ -132,10 +132,8 @@ function summary(id: SessionId, updatedAt: number): SessionSummary {
}
function hoverCatalog(trigger: HTMLElement): void {
const root = trigger.parentElement
if (root === null) throw new Error('catalog trigger has no root')
vi.useFakeTimers()
fireEvent.mouseEnter(root)
fireEvent.mouseEnter(trigger)
act(() => { vi.advanceTimersByTime(150) })
}
@@ -255,7 +253,8 @@ describe('SubagentHeaderLineage', () => {
expect(screen.getByRole('button', { name: '展开 worker 的下级子代理' })).toBeTruthy()
expect(screen.getByRole('button', { name: '展开 reviewer 的下级子代理' })).toBeTruthy()
const sidebarButton = screen.getByRole('button', { name: '在侧边栏打开 worker' })
const sidebarButton = within(screen.getByRole('treeitem', { name: /worker/ }))
.getByRole('button', { name: '在侧边栏打开 worker' })
fireEvent.keyDown(sidebarButton, { key: 'Enter' })
fireEvent.click(sidebarButton)
expect(input.openChildAside).toHaveBeenCalledWith({
@@ -331,7 +330,7 @@ describe('SubagentHeaderLineage', () => {
expect(screen.queryByRole('tree')).toBeNull()
})
it('opens only on hover and preserves the portaled-menu crossing grace', async () => {
it('opens on hover and preserves the portaled-menu crossing grace', async () => {
vi.useFakeTimers()
const advance = async (duration: number): Promise<void> => {
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
@@ -341,26 +340,23 @@ describe('SubagentHeaderLineage', () => {
const triggerRect = vi.spyOn(trigger, 'getBoundingClientRect')
.mockReturnValue({ bottom: 40, left: 50 } as DOMRect)
fireEvent.click(trigger)
expect(screen.queryByRole('tree')).toBeNull()
fireEvent.mouseEnter(trigger.parentElement!)
fireEvent.mouseEnter(trigger)
await advance(149)
expect(screen.queryByRole('tree')).toBeNull()
await advance(1)
const tree = screen.getByRole('tree')
expect(tree.style.top).toBe('45px')
expect(tree.style.left).toBe('50px')
const menu = screen.getByRole('tree').parentElement!
expect(menu.style.top).toBe('45px')
expect(menu.style.left).toBe('50px')
triggerRect.mockReturnValue({ bottom: 60, left: 70 } as DOMRect)
fireEvent.resize(window)
expect(tree.style.top).toBe('65px')
expect(tree.style.left).toBe('70px')
expect(menu.style.top).toBe('65px')
expect(menu.style.left).toBe('70px')
fireEvent.mouseLeave(trigger.parentElement!)
fireEvent.mouseEnter(tree)
fireEvent.mouseEnter(menu)
await advance(120)
expect(screen.getByRole('tree')).toBeTruthy()
fireEvent.mouseLeave(tree)
fireEvent.mouseLeave(menu)
await advance(119)
expect(screen.getByRole('tree')).toBeTruthy()
await advance(1)
@@ -372,25 +368,89 @@ describe('SubagentHeaderLineage', () => {
await advance(120)
})
it('pins a click-opened catalog through hover-out until outside pointer or Escape', async () => {
vi.useFakeTimers()
const advance = async (duration: number): Promise<void> => {
await act(async () => { await vi.advanceTimersByTimeAsync(duration) })
}
render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
const root = trigger.parentElement!
fireEvent.click(trigger)
const menu = screen.getByRole('tree')
fireEvent.mouseEnter(trigger)
fireEvent.mouseLeave(root)
await advance(300)
expect(screen.getByRole('tree')).toBe(menu)
fireEvent.mouseEnter(menu.parentElement!)
fireEvent.mouseLeave(menu.parentElement!)
await advance(300)
expect(screen.getByRole('tree')).toBe(menu)
fireEvent.pointerDown(document.body)
expect(screen.queryByRole('tree')).toBeNull()
fireEvent.mouseEnter(trigger)
await advance(150)
const hovered = screen.getByRole('tree')
fireEvent.click(trigger)
fireEvent.mouseLeave(root)
await advance(300)
expect(screen.getByRole('tree')).toBe(hovered)
fireEvent.keyDown(hovered, { key: 'Escape' })
expect(screen.queryByRole('tree')).toBeNull()
fireEvent.mouseEnter(trigger)
fireEvent.mouseLeave(root)
await advance(300)
expect(screen.queryByRole('tree')).toBeNull()
})
it('keeps the hover catalog open when the pointer returns directly to its trigger', async () => {
vi.useFakeTimers()
render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.mouseOver(trigger, { relatedTarget: document.body })
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
const menu = screen.getByRole('tree').parentElement!
fireEvent.mouseOut(trigger, { relatedTarget: menu })
fireEvent.mouseOver(menu, { relatedTarget: trigger })
fireEvent.mouseOut(menu, { relatedTarget: trigger })
fireEvent.mouseOver(trigger, { relatedTarget: menu })
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
expect(screen.getByRole('tree').parentElement).toBe(menu)
})
it('cancels pending hover dismissal when the catalog trigger is activated from the keyboard', async () => {
vi.useFakeTimers()
render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.mouseOver(trigger, { relatedTarget: document.body })
await act(async () => { await vi.advanceTimersByTimeAsync(150) })
fireEvent.mouseOut(trigger, { relatedTarget: document.body })
fireEvent.click(trigger, { detail: 0 })
await act(async () => { await vi.advanceTimersByTimeAsync(120) })
expect(screen.getByRole('tree')).toBeTruthy()
})
it('repositions an open catalog after viewport resize and document scroll', () => {
const view = render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
const bounds = vi.spyOn(trigger, 'getBoundingClientRect')
bounds.mockReturnValue({ bottom: 20, left: 30 } as DOMRect)
hoverCatalog(trigger)
const tree = screen.getByRole('tree')
expect(tree.style.top).toBe('25px')
expect(tree.style.left).toBe('30px')
const menu = screen.getByRole('tree').parentElement!
expect(menu.style.top).toBe('25px')
expect(menu.style.left).toBe('30px')
bounds.mockReturnValue({ bottom: 70, left: 80 } as DOMRect)
act(() => { window.dispatchEvent(new Event('resize')) })
expect(tree.style.top).toBe('75px')
expect(tree.style.left).toBe('80px')
expect(menu.style.top).toBe('75px')
expect(menu.style.left).toBe('80px')
bounds.mockReturnValue({ bottom: 90, left: 100 } as DOMRect)
act(() => { document.dispatchEvent(new Event('scroll')) })
expect(tree.style.top).toBe('95px')
expect(tree.style.left).toBe('100px')
expect(menu.style.top).toBe('95px')
expect(menu.style.left).toBe('100px')
view.unmount()
})
@@ -399,7 +459,7 @@ describe('SubagentHeaderLineage', () => {
const view = render(<HeaderCatalog {...props(catalog())} />)
const trigger = screen.getByRole('button', { name: /1 个子代理,正在运行/ })
fireEvent.mouseEnter(trigger.parentElement!)
fireEvent.mouseEnter(trigger)
await vi.advanceTimersByTimeAsync(149)
view.rerender(<HeaderCatalog {...props(catalog({ entries: [] }))} />)
expect(screen.queryByRole('button')).toBeNull()
@@ -4,6 +4,7 @@ import type { Context } from '@deepseek-ai/cordis'
import {
IconAgentPresetOutlineRegular, IconBranchOutlineRegular, IconChecklistOutlineRegular, IconClockOutlineRegular,
IconCodeOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular, IconSearchOutlineRegular,
IconUsersOutlineRegular,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
import type { ToolCallViewProps } from '../../contract/slots.ts'
@@ -59,7 +60,11 @@ const LSP_TITLE_KEYS = {
hover: 'tool.title.hoverSymbol',
} as const
/** Teammate-coordination tools presented with the two-person team icon. */
const TEAMMATE_TOOLS = new Set(['spawn_teammate', 'list_agents', 'send_message', 'interrupt_agent', 'wait_agent'])
function detailIcon(toolName: string) {
if (TEAMMATE_TOOLS.has(toolName)) return <IconUsersOutlineRegular size={14} />
if (toolName.startsWith('schedule_')) return <IconClockOutlineRegular size={14} />
if (toolName.endsWith('_goal')) return <IconGoalOutlineRegular size={14} />
if (toolName.startsWith('cordis_')) return <IconCordisPluginOutlineRegular />
@@ -3,6 +3,7 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import { IconUsersOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { en } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts'
import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts'
@@ -90,6 +91,14 @@ describe('DetailsRow', () => {
expect(inspect).toHaveBeenCalledOnce()
})
it('presents teammate-coordination tools under the two-person team icon', () => {
const inspect = vi.fn()
const expectedIcon = render(<IconUsersOutlineRegular size={14} />).container.querySelector('svg')!.outerHTML
const view = render(<DetailsRow {...{ useDisclosure, toolName: 'wait_agent', block: result('wait_agent', { timedOut: true }), inspect, t } as Parameters<typeof DetailsRow>[0]} />)
expect(screen.getByText('Wait for teammates')).toBeTruthy()
expect(view.container.querySelector('svg')?.outerHTML).toBe(expectedIcon)
})
it('registers the supported tool names through the scoped keyed slot', () => {
const register = vi.fn((_spec: unknown, _component: unknown) => () => undefined)
const inject = vi.fn((_name: string, callback: () => Iterable<() => void>) => {
@@ -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/experimental/agent-team/README.md
README.md: 6ae7d82214ca9e041324e353057ba6850675c043
README.zh.md: 89176c285a8bd9d5a1875618dffb0b2eb4890a3e
README.md: 6d2aa6041039022e6508541fdb738181bbb54045
README.zh.md: 9ea2977a976583aac08ca8bc53d61ff4812e1766
+7 -3
View File
@@ -118,7 +118,8 @@ The [Agent Teams Agent Note](../../../.agents/notes/implemented/feature/2026-08-
| [`src/mailbox.ts`](src/mailbox.ts) | Durable queue, target-local dispatch, acknowledgement, and recovery |
| [`src/task-board.ts`](src/task-board.ts) | Task CAS commands, DAG validation, and derived views |
| [`src/journal.ts`](src/journal.ts) | Serialized Lead-log transactions and commit notification |
| [`src/projection.ts`](src/projection.ts) | Strict replay projection that decodes and validates Team events |
| [`src/projection.ts`](src/projection.ts) | Strict replay projection that decodes and validates Team events and publishes the `agentTeam` client view |
| [`src/task-view.ts`](src/task-view.ts) | Pure task readiness, owner-name, and write-overlap derivation shared by the task board and the client view |
| [`src/activity.ts`](src/activity.ts) | One-shot change waiters and disposal release |
| [`src/lifecycle.ts`](src/lifecycle.ts) | Shared admission cutoff and bounded settlement |
| [`src/invariant.ts`](src/invariant.ts) | Invariant companion that replays candidate events before append |
@@ -171,9 +172,11 @@ Read these pages when the package-level contract is not enough. They move from t
<a id="model-experience"></a>
### Browser Remote
### Browser projection
`TeamService` exposes the read-only `agentTeams/view` Remote method for browser clients. Task creation and updates belong to Team agents through the service and model tools. The `./remote` export supplies the Client contribution mounted by the Web UI, while `./client` exports browser-safe roster and task views.
The `agentTeam` Session projection publishes the Lead Session's durable roster identities and phases, member errors, non-deleted task views, and any `failure` beside the last valid state. Its `apply` replaces only the touched collection; mailbox-only changes retain the client view reference and produce no frame. The [subsystem reference](../../../docs/subsystems/agent-team.md#web-projection) defines the wire types.
The [Web UI](../client-ui-agent-team/README.md) reads the shared Session projections and overlays activity from Session status. Task creation and updates belong to Team agents through the service and model tools. The `./client` export supplies browser-safe roster, task, and projection types.
## Model Experience
@@ -198,6 +201,7 @@ Peer messages append after the target's reusable history prefix. Cold resume reu
These limits describe what a team cannot do yet or what needs special operational care. They are current package constraints, not a comparison with other coordination mechanisms.
- **Whole-view broadcasts** — each roster or task change sends the complete roster and non-deleted task board, including descriptions, to every connected browser, even when it is viewing another Session.
- **Experimental prototype with no stability promise** — the package is public, but its contracts can change freely while it incubates.
- **One process and one shared checkout** — members share cwd and observe edits immediately; this package provides no worktree, remote member, merge, or filesystem lock.
- **Advisory write scopes** — Bash, formatters, code generators, and direct external writers can bypass filesystem version checks; Leads must coordinate ownership and review the final diff.
@@ -118,7 +118,8 @@ Lead 可以停止 teammate 的当前轮次,而不会删除其排队的消息
| [`src/mailbox.ts`](src/mailbox.ts) | 持久队列、目标本地投递、确认与恢复 |
| [`src/task-board.ts`](src/task-board.ts) | 任务 CAS 命令、DAG 校验与派生视图 |
| [`src/journal.ts`](src/journal.ts) | 串行化的 Lead 日志事务与提交通知 |
| [`src/projection.ts`](src/projection.ts) | 解码并校验 Team 事件的严格回放投影 |
| [`src/projection.ts`](src/projection.ts) | 解码并校验 Team 事件、发布 `agentTeam` 客户端视图的严格回放投影 |
| [`src/task-view.ts`](src/task-view.ts) | 任务板与客户端视图共用的纯任务派生:就绪状态、owner 名称与写入范围重叠 |
| [`src/activity.ts`](src/activity.ts) | 一次性变更等待者与 dispose(资源释放)时的等待解除 |
| [`src/lifecycle.ts`](src/lifecycle.ts) | 共享准入截止与有界结算 |
| [`src/invariant.ts`](src/invariant.ts) | 在 append 前回放候选事件的不变式伴生插件 |
@@ -171,9 +172,11 @@ dispose 会关闭准入、中止并等待已获准的创建与 mailbox dispatch
<a id="model-experience"></a>
### 浏览器 Remote
### 浏览器投影
`TeamService` 向浏览器客户端公开只读的 `agentTeams/view` Remote method。任务创建与更新由 Team agent 通过服务和模型工具执行。`./remote` 导出由 Web UI 挂载的 Client contribution,`./client` 导出可供浏览器使用的成员与任务视图。
`agentTeam` Session 投影发布 Lead Session 的持久成员身份与阶段、成员错误、未删除任务视图,以及最后有效状态旁的 `failure`。其 `apply` 只替换被触及的集合;仅邮箱的变化保留客户端视图引用,不产生 frame。[子系统参考](../../../docs/subsystems/agent-team.zh.md#web-projection) 定义传输类型。
[Web UI](../client-ui-agent-team/README.zh.md) 读取共享 Session 投影,并从 Session 状态叠加活动信息。任务创建与更新由 Team agent 通过服务和模型工具完成。`./client` 导出可供浏览器使用的 roster、任务与投影类型。
## 模型体验
@@ -198,6 +201,7 @@ Peer 消息追加在 target 可复用历史前缀之后。冷恢复会先复用
这些限制说明一支团队目前不能做什么、或哪些方面需要特别的运维关注。它们是当前包约束,不是与其他协作机制的对比。
- **完整视图广播** — 每次 roster 或任务变化都会把完整 roster 和未删除任务板(含描述)发给所有已连接浏览器,即使它正在查看其他 Session。
- **实验原型,无稳定性承诺**——本包公开发布,但孵化期间约定仍可自由变更。
- **单进程、共享 checkout**——成员共享 cwd,修改立即可见;本包不提供 worktree、远端成员、merge 或文件锁。
- **write scope 仅作提示**——Bash、formatter、代码生成器与直接外部写入可以绕过文件版本检查;Lead 必须协调 owner 并检查最终 diff。
+3 -17
View File
@@ -30,14 +30,6 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
},
"./remote": {
"types": "./lib/typert.remote-client.d.ts",
"default": "./lib/typert.remote-client.js"
},
"./locale/*.json": "./locale/*.json",
"./src/*": "./src/*",
"./package.json": "./package.json"
@@ -47,11 +39,7 @@
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts",
"lib/typert.host.js",
"lib/typert.host.d.ts",
"lib/typert.remote-client.js",
"lib/typert.remote-client.d.ts"
"lib/types/**/*.d.ts"
],
"license": "MIT",
"dependencies": {
@@ -67,8 +55,7 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-persistence": "workspace:*",
"@deepseek-ai/dsh-session-projection": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
"@deepseek-ai/dsh-subagent": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~",
@@ -84,7 +71,6 @@
"@deepseek-ai/dsh-session-query": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*",
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*"
}
}
@@ -1,9 +1,11 @@
/** Client-safe Agent Teams roster and task views. */
/** Client-safe Agent Teams roster and task views plus the `agentTeam` projection vocabulary. */
export type {
TeamMemberPhase,
TeamMemberProjection,
TeamMemberView,
TeamProjection,
TeamTaskId,
TeamTaskStatus,
TeamTaskView,
TeamView,
} from './types.ts'
+2 -17
View File
@@ -1,10 +1,9 @@
/** Agent Teams service façade over roster, mailbox, task, and runtime lifecycle owners. */
import { Context } from '@deepseek-ai/cordis'
import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {} from '@deepseek-ai/dsh-session-persistence'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { TeamActivity } from './activity.ts'
import { errorMessage, TeamError } from './error.ts'
import { TeamJournal } from './journal.ts'
@@ -24,7 +23,6 @@ import type {
SpawnTeammateResult,
TeamMemberView,
TeamTaskView,
TeamView,
TeamWaitResult,
UpdateTeamTaskRequest,
} from './types.ts'
@@ -55,7 +53,7 @@ function positiveLimit(name: string, value: number): number {
}
/** Agent Teams service backed by the exact live Lead Session log. */
export class TeamService extends TypertRemoteService {
export class TeamService extends Service {
static inject = ['agents', 'sessions', 'sessionPersistence', 'sessionProjections', 'subagents']
static Config: z<Config> = z.object({
@@ -233,19 +231,6 @@ export class TeamService extends TypertRemoteService {
return this.roster.tryMembership(agent)
}
/**
* Read the current roster and non-deleted task board through the generated Remote API.
* @param agent - exact live Team member used as the authority credential.
* @returns detached current roster and task views.
*/
@Remote('view')
remoteView(agent: Agent): TeamView {
return {
members: this.listMembers(agent),
tasks: this.listTasks(agent),
}
}
/** Queue one contained recovery pass after publication has unwound. */
private scheduleRecovery(agent: Agent): void {
queueMicrotask(() => {
@@ -16,7 +16,7 @@ export const name = 'team-invariant'
/** Invariant registry required by the companion. */
export const inject = ['invariants']
/** Validate candidate Team events against the projected committed prefix. */
/** Validate candidate Team events against the projected committed prefix; `apply` never mutates the committed state. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
ctx.on('internal/dispatch', (_mode, eventName, args) => {
if (eventName !== 'session/event') return
@@ -24,7 +24,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
/* v8 ignore next -- non-Team Session events have no Agent Teams invariant. */
if (!isTeamEvent(event)) return
const state = ctx.sessionProjections.stateOf(session, 'agentTeam') as TeamProjectionState
const candidate = teamProjectionDefinition.apply(structuredClone(state), event)
const candidate = teamProjectionDefinition.apply(state, event)
if (candidate.failure !== undefined) {
fail(`session event ${event.seq} violates the Agent Teams stream: ${candidate.failure}`)
}
@@ -1,4 +1,4 @@
/** Host-only Team state projected incrementally from committed Session events. */
/** Team state projected incrementally from committed Session events, with a durable-only client view. */
import { z } from 'zod'
import { brandString } from '@deepseek-ai/dsh-brand'
@@ -7,10 +7,13 @@ import type { SessionEvent, SessionEventMap, SessionId } from '@deepseek-ai/dsh-
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type {
TeamId,
TeamMemberProjection,
TeamMemberSnapshot,
TeamMessageId,
TeamMessageSnapshot,
TeamProjection,
TeamTaskSnapshot,
TeamTaskView,
} from './types.ts'
import {
TeamId as toTeamId,
@@ -18,6 +21,7 @@ import {
TeamTaskId as toTeamTaskId,
} from './types.ts'
import { assertTaskGraphCandidate } from './task-graph.ts'
import { projectTaskView } from './task-view.ts'
const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER)
const positiveSafeInteger = nonNegativeSafeInteger.min(1)
@@ -119,20 +123,24 @@ const teamMessageDeliveredEventSchema = z.object({
targetId: sessionIdSchema,
}).strict() as z.ZodType<SessionEventMap['team/message/delivered']>
/** Current Team state selected by durable Team identity. */
/**
* Current Team state selected by durable Team identity. Every applied Team
* event produces a new state object and replaces only the collection it
* touched; untouched collections keep their references.
*/
export interface TeamState {
readonly id: TeamId
readonly members: TeamMemberSnapshot[]
readonly tasks: TeamTaskSnapshot[]
readonly messages: TeamMessageSnapshot[]
readonly delivered: TeamMessageId[]
nextTaskNumber: number
readonly members: readonly TeamMemberSnapshot[]
readonly tasks: readonly TeamTaskSnapshot[]
readonly messages: readonly TeamMessageSnapshot[]
readonly delivered: readonly TeamMessageId[]
readonly nextTaskNumber: number
}
/**
* Construct empty state for one Team identity.
* @param rootId - root Session identity.
* @returns mutable empty Team state.
* @returns empty Team state.
*/
export function emptyTeamState(rootId: SessionId): TeamProjectionState {
return {
@@ -147,7 +155,7 @@ export function emptyTeamState(rootId: SessionId): TeamProjectionState {
/** Checkpoint-safe state for the Team owned by the projected Session. */
export interface TeamProjectionState extends TeamState {
failure?: string
readonly failure?: string
}
declare module '@deepseek-ai/dsh-session-projection/types' {
@@ -214,23 +222,30 @@ function parseCurrentTeamEvent(event: TeamSessionEvent): TeamSessionEvent {
}
}
function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): void {
if (state.failure !== undefined) return
if (!isTeamEvent(event)) return
function applyProjectionEvent(state: TeamProjectionState, event: SessionEvent): TeamProjectionState {
if (state.failure !== undefined) return state
if (!isTeamEvent(event)) return state
try {
const selector = parsePersisted(event.type, teamEventSelectorSchema, event.data)
if (selector.teamId !== state.id) return
if (selector.teamId !== state.id) return state
if (selector.version !== 2) {
throw new Error(`unsupported Agent Teams event version ${String(selector.version)}`)
}
applyCurrentTeamEvent(state, parseCurrentTeamEvent(event))
return applyCurrentTeamEvent(state, parseCurrentTeamEvent(event))
} catch (error: unknown) {
/* v8 ignore next -- the owned Team transition throws Error instances. */
state.failure = error instanceof Error ? error.message : String(error)
return { ...state, failure: error instanceof Error ? error.message : String(error) }
}
}
function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void {
function replaceAt<T>(items: readonly T[], index: number, item: T): T[] {
const next = [...items]
if (index < 0) next.push(item)
else next[index] = item
return next
}
function applyCurrentTeamEvent(state: TeamProjectionState, event: TeamSessionEvent): TeamProjectionState {
switch (event.type) {
case 'team/member': {
const member = event.data.member
@@ -250,9 +265,7 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void
throw new Error(`teammate "${member.name}" has an invalid ${prior.phase} -> ${member.phase} transition`)
}
}
if (index < 0) state.members.push(member)
else state.members[index] = member
break
return { ...state, members: replaceAt(state.members, index, member) }
}
case 'team/task': {
const task = event.data.task
@@ -265,48 +278,117 @@ function applyCurrentTeamEvent(state: TeamState, event: TeamSessionEvent): void
throw new Error(`team task "${task.id}" revision is not contiguous`)
}
assertTaskGraphCandidate(state.tasks, task)
let nextTaskNumber = state.nextTaskNumber
const match = numericTaskIdPattern.exec(task.id)
if (match !== null) {
const number = Number(match[1])
state.nextTaskNumber = Math.max(
state.nextTaskNumber,
nextTaskNumber = Math.max(
nextTaskNumber,
number === Number.MAX_SAFE_INTEGER ? number : number + 1,
)
}
if (index < 0) state.tasks.push(task)
else state.tasks[index] = task
break
return { ...state, tasks: replaceAt(state.tasks, index, task), nextTaskNumber }
}
case 'team/message/queued': {
const message = event.data.message
if (state.messages.some(candidate => candidate.id === message.id)) {
throw new Error(`team message "${message.id}" was queued twice`)
}
state.messages.push(message)
break
return { ...state, messages: [...state.messages, message] }
}
case 'team/message/delivered': {
const queued = state.messages.find(message => message.id === event.data.messageId)
if (queued === undefined) throw new Error(`team message "${event.data.messageId}" was delivered before queueing`)
if (queued.targetId !== event.data.targetId) throw new Error(`team message "${event.data.messageId}" target changed`)
if (state.delivered.includes(event.data.messageId)) throw new Error(`team message "${event.data.messageId}" was delivered twice`)
state.delivered.push(event.data.messageId)
break
return { ...state, delivered: [...state.delivered, event.data.messageId] }
}
/* v8 ignore next 2 -- TeamEventType is closed and every member is handled above. */
default:
return
return state
}
}
/** Host-only Team projection selected by the projected Session identity. */
const teamMemberProjectionSchema = z.object({
id: sessionIdSchema,
name: z.string(),
role: z.enum(['lead', 'teammate']),
phase: z.enum(['provisioning', 'active', 'failed']),
error: z.string().optional(),
}).strict() as z.ZodType<TeamMemberProjection>
const teamTaskViewSchema = z.object({
id: teamTaskIdSchema,
revision: positiveSafeInteger,
subject: z.string(),
description: z.string(),
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
blockedBy: z.array(teamTaskIdSchema),
writeScopes: z.array(z.string()),
ownerName: z.string().optional(),
ready: z.boolean(),
writeScopeWarnings: z.array(z.string()),
}).strict() as z.ZodType<TeamTaskView>
const teamProjectionSchema = z.object({
members: z.array(teamMemberProjectionSchema),
tasks: z.array(teamTaskViewSchema),
failure: z.string().optional(),
}).strict() as z.ZodType<TeamProjection>
/** Client views keyed by the member and task collections they were derived from. */
const teamProjectionViews = new WeakMap<readonly TeamMemberSnapshot[], WeakMap<readonly TeamTaskSnapshot[], TeamProjection>>()
function buildTeamProjection(state: TeamProjectionState): TeamProjection {
const rootId = brandString<SessionId>(state.id)
const members: TeamMemberProjection[] = [{ id: rootId, name: 'lead', role: 'lead', phase: 'active' }]
for (const member of state.members) {
members.push({
id: member.id,
name: member.name,
role: 'teammate',
phase: member.phase,
...member.error === undefined ? {} : { error: member.error },
})
}
return {
members,
tasks: state.tasks
.filter(task => task.status !== 'deleted')
.map(task => projectTaskView(state, task)),
...state.failure === undefined ? {} : { failure: state.failure },
}
}
/**
* Durable client view of one Team state. Mailbox-only state changes reuse the
* previous view reference, so the live drive publishes nothing for them.
* A failure is terminal: later events retain the failed state reference and
* do not republish its view.
* @param state - current Team state.
* @returns the roster and non-deleted task board, plus any projection failure.
*/
export function teamProjectionView(state: TeamProjectionState): TeamProjection {
if (state.failure !== undefined) return buildTeamProjection(state)
let byTasks = teamProjectionViews.get(state.members)
if (byTasks === undefined) {
byTasks = new WeakMap()
teamProjectionViews.set(state.members, byTasks)
}
let view = byTasks.get(state.tasks)
if (view === undefined) {
view = buildTeamProjection(state)
byTasks.set(state.tasks, view)
}
return view
}
/** Team projection selected by the projected Session identity; the wire view carries durable roster and task state only. */
export const teamProjectionDefinition = {
key: 'agentTeam',
stateVersion: 4,
stateSchema: teamProjectionEntrySchema,
init: header => emptyTeamState(header.id),
apply: (state, event) => {
applyProjectionEvent(state, event)
return state
},
apply: applyProjectionEvent,
wire: { viewSchema: teamProjectionSchema, view: teamProjectionView },
} satisfies ProjectionDefinition<'agentTeam', TeamProjectionState>
@@ -15,13 +15,9 @@ import type {
TeamTaskView,
UpdateTeamTaskRequest,
} from './types.ts'
import { projectTaskView, taskReady } from './task-view.ts'
import { requiredText, writeScope } from './validation.ts'
/** Whether two normalized file or directory prefixes overlap on path components. */
function scopesOverlap(left: string, right: string): boolean {
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
}
const TASK_GRAPH_ERROR_CODES: Record<TeamTaskGraphViolation, string> = {
missing: 'TEAM_TASK_NOT_FOUND',
duplicate: 'TEAM_INVALID_ARGUMENT',
@@ -68,7 +64,7 @@ export class TeamTaskBoard {
}
this.assertTaskGraph(state, task)
await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task })
return this.taskView(root, state, task)
return projectTaskView(state, task)
})
}
@@ -83,7 +79,7 @@ export class TeamTaskBoard {
const state = this.journal.state(root)
const task = state.tasks.find(candidate => candidate.id === id)
if (task === undefined) throw new TeamError(`team task "${id}" not found`, 'TEAM_TASK_NOT_FOUND')
return this.taskView(root, state, task)
return projectTaskView(state, task)
}
/**
@@ -96,7 +92,7 @@ export class TeamTaskBoard {
const state = this.journal.state(root)
return state.tasks
.filter(task => task.status !== 'deleted')
.map(task => this.taskView(root, state, task))
.map(task => projectTaskView(state, task))
}
/**
@@ -134,7 +130,7 @@ export class TeamTaskBoard {
if (current.ownerId !== undefined && current.ownerId !== caller.id) {
throw new TeamError(`team task "${current.id}" is owned by another member`, 'TEAM_TASK_ALREADY_CLAIMED')
}
if (current.status !== 'pending' || !this.taskReady(state, current)) {
if (current.status !== 'pending' || !taskReady(state, current)) {
throw new TeamError(`team task "${current.id}" is not ready to claim`, 'TEAM_TASK_BLOCKED')
}
next = { ...current, status: 'in_progress', ownerId: caller.id }
@@ -185,7 +181,7 @@ export class TeamTaskBoard {
next = this.withoutOwner({ ...current, status: 'pending' })
break
}
if (!this.taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED')
if (!taskReady(state, current)) throw new TeamError(`team task "${current.id}" is blocked`, 'TEAM_TASK_BLOCKED')
const assignee = resolveActiveMember(root, state, request.owner)
next = { ...current, status: 'in_progress', ownerId: assignee.id }
break
@@ -210,7 +206,7 @@ export class TeamTaskBoard {
}
this.assertTaskGraph(state, task)
await this.journal.appendAndFlush(root, 'team/task', { version: 2, teamId: TeamId(root.id), task })
return this.taskView(root, state, task)
return projectTaskView(state, task)
})
}
@@ -251,47 +247,9 @@ export class TeamTaskBoard {
}
}
/** Whether all current blockers completed. */
private taskReady(state: TeamState, task: TeamTaskSnapshot): boolean {
return task.blockedBy.every(id => state.tasks.find(candidate => candidate.id === id)?.status === 'completed')
}
/** Remove an optional owner field under exactOptionalPropertyTypes. */
private withoutOwner(task: TeamTaskSnapshot): TeamTaskSnapshot {
const { ownerId: _ownerId, ...without } = task
return without
}
/**
* Build one task view with owner name, readiness, and advisory write overlaps.
* A committing caller may pass its pre-append state because `task` supplies the
* new value explicitly; owner names, blocker readiness, and other task scopes
* do not change when that snapshot is appended.
*/
private taskView(root: Agent, state: TeamState, task: TeamTaskSnapshot): TeamTaskView {
const ownerName = task.ownerId === undefined
? undefined
: task.ownerId === root.id
? 'lead'
: state.members.find(member => member.id === task.ownerId)?.name
const warnings = new Set<string>()
for (const other of state.tasks) {
if (other.id === task.id || other.status !== 'in_progress') continue
if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) {
warnings.add(`write scopes overlap with ${other.id}`)
}
}
return {
id: task.id,
revision: task.revision,
subject: task.subject,
description: task.description,
status: task.status,
blockedBy: structuredClone(task.blockedBy),
writeScopes: structuredClone(task.writeScopes),
...ownerName === undefined ? {} : { ownerName },
ready: task.status === 'pending' && this.taskReady(state, task),
writeScopeWarnings: [...warnings],
}
}
}
@@ -0,0 +1,62 @@
/** Pure task-view derivation shared by the task board and the client projection. */
import { brandString } from '@deepseek-ai/dsh-brand'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { TeamState } from './projection.ts'
import type { TeamTaskSnapshot, TeamTaskView } from './types.ts'
/**
* Whether two normalized file or directory prefixes overlap on path components.
* @param left - normalized write scope.
* @param right - normalized write scope.
* @returns whether either scope contains the other.
*/
function scopesOverlap(left: string, right: string): boolean {
return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`)
}
/**
* Whether every current blocker of one task completed.
* @param state - Team state supplying sibling tasks.
* @param task - durable task snapshot to test.
* @returns whether the task has no incomplete blockers.
*/
export function taskReady(state: TeamState, task: TeamTaskSnapshot): boolean {
return task.blockedBy.every(id => state.tasks.find(candidate => candidate.id === id)?.status === 'completed')
}
/**
* Derive one task view with owner name, readiness, and advisory write overlaps.
* A committing caller may pass its pre-append state because `task` supplies the
* new value explicitly; owner names, blocker readiness, and other task scopes
* do not change when that snapshot is appended.
* @param state - Team state supplying members and sibling tasks.
* @param task - durable task snapshot to view.
* @returns a detached task view.
*/
export function projectTaskView(state: TeamState, task: TeamTaskSnapshot): TeamTaskView {
const ownerName = task.ownerId === undefined
? undefined
: task.ownerId === brandString<SessionId>(state.id)
? 'lead'
: state.members.find(member => member.id === task.ownerId)?.name
const warnings = new Set<string>()
for (const other of state.tasks) {
if (other.id === task.id || other.status !== 'in_progress') continue
if (task.writeScopes.some(left => other.writeScopes.some(right => scopesOverlap(left, right)))) {
warnings.add(`write scopes overlap with ${other.id}`)
}
}
return {
id: task.id,
revision: task.revision,
subject: task.subject,
description: task.description,
status: task.status,
blockedBy: [...task.blockedBy],
writeScopes: [...task.writeScopes],
...ownerName === undefined ? {} : { ownerName },
ready: task.status === 'pending' && taskReady(state, task),
writeScopeWarnings: [...warnings],
}
}
+25 -3
View File
@@ -96,10 +96,32 @@ export interface TeamTaskView {
readonly writeScopeWarnings: string[]
}
/** Point-in-time roster and task-board projection returned to browser clients. */
export interface TeamView {
readonly members: TeamMemberView[]
/** One durable roster row published through the `agentTeam` Session projection. */
export interface TeamMemberProjection {
readonly id: SessionId
readonly name: string
readonly role: 'lead' | 'teammate'
/** Durable lifecycle; the Lead row is always `active`. Turn activity comes from Session status. */
readonly phase: TeamMemberPhase
readonly error?: string
}
/**
* Durable Team state published to browser clients through the Lead Session's
* `agentTeam` projection. `failure` names the first rejected persisted Team
* record; members and tasks then stay at the last valid state.
*/
export interface TeamProjection {
readonly members: TeamMemberProjection[]
readonly tasks: TeamTaskView[]
readonly failure?: string
}
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
/** Durable roster and non-deleted task board of the Team rooted at the projected Session. */
agentTeam: TeamProjection
}
}
/** One peer message retained until its target Session records it. */
@@ -1,4 +1,4 @@
/** Plain-Node smoke for the built Agent Teams service and Remote contribution. */
/** Plain-Node smoke for the built Agent Teams service. */
import { execFile } from 'node:child_process'
import { existsSync } from 'node:fs'
@@ -13,21 +13,17 @@ const artifactUrl = (path: string): string => pathToFileURL(artifact(path)).href
const requiredArtifacts = [
'packages/experimental/agent-team/lib/index.js',
'packages/experimental/agent-team/lib/typert.remote-client.js',
].every(path => existsSync(artifact(path)))
describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => {
it('loads the Host service and its generated browser contribution under plain Node', async () => {
it('loads the Host service under plain Node', async () => {
const urls = {
host: artifactUrl('packages/experimental/agent-team/lib/index.js'),
remote: artifactUrl('packages/experimental/agent-team/lib/typert.remote-client.js'),
}
const script = `
const host = await import(${JSON.stringify(urls.host)})
const remote = await import(${JSON.stringify(urls.remote)})
console.log(JSON.stringify({
className: host.default.name,
methods: remote.default.descriptors.map(descriptor => descriptor.id),
}))
`
@@ -35,13 +31,9 @@ describe.skipIf(!requiredArtifacts)('Agent Teams built LIB service', () => {
expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0)
const output = JSON.parse(result.stdout.trim().split('\n').at(-1) ?? '{}') as {
className: string
methods: string[]
}
expect(output).toEqual({
className: 'TeamService',
methods: [
'@deepseek-ai/dsh-experimental-agent-team#agentTeams/view',
],
})
})
})
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff. Show more