Merge branch 'worktree/process-plugin-streaming-72f9b3' into worktree/job-kill-delivery

# Conflicts:
#	apps/web/tests/background-job-list.e2e.ts
#	apps/web/tests/live-job-stream.e2e.ts
#	docs/config-catalog.i18n.yaml
#	docs/config-catalog.md
#	docs/config-catalog.zh.md
#	docs/event-producer-consumer.i18n.yaml
#	docs/event-producer-consumer.md
#	packages/client/ui-activity/README.md
#	packages/client/ui-activity/README.zh.md
#	packages/client/ui-activity/src/client/index.ts
#	packages/client/ui-jobs/README.i18n.yaml
#	packages/client/ui-jobs/src/client/JobListAction.tsx
#	packages/client/ui-jobs/tests/browser-plugin.client.spec.ts
#	packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
#	packages/jobs/jobs-local/src/index.ts
#	packages/jobs/jobs/README.i18n.yaml
#	packages/jobs/jobs/README.md
#	packages/jobs/jobs/README.zh.md
#	packages/jobs/jobs/src/index.ts
#	packages/shell/tool-bash/src/index.ts
#	packages/shell/tool-bash/tests/observe.spec.ts
#	packages/shell/tool-pwsh/src/index.ts
#	packages/shell/tool-pwsh/tests/observe.spec.ts
#	packages/workflow/tool-workflow/tests/activity-mirror.spec.ts
#	pnpm-lock.yaml
This commit is contained in:
Yichen Jiang committed 2026-09-01 19:35:23 +08:00
commit a2fb67d06c
187 files changed
+3599 -7085

No files matched your search

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md
2026-09-01-jobs-absorb-activity-record.md: cc2cc8606a50e2d7c06e183a09df709b75b9bd35
2026-09-01-jobs-absorb-activity-record.zh.md: 8b6739192b56899899773ba1e7af2bf1bd846b0b
@@ -0,0 +1,35 @@
# Agent Note: The job registry absorbs the observation record; the standalone activity seam is removed
Status: implemented
English | [中文](2026-09-01-jobs-absorb-activity-record.zh.md)
## Problem
The [activity observation seam](../feature/2026-08-24-activity-observation-seam.md) shipped live output streaming as a second registry beside `ctx.jobs`: producers registered the same work twice (a job for lifecycle, an activity for observation), kept the two terminal states consistent by hand (`observeBackgroundActivity` waited for `proc.done` before mapping the outcome so a pump failure could not freeze a wrong terminal state), correlated the rows through `ActivityCorrelation.jobId`, and wrapped every observation call in registry-absent degradation branches. The Web client maintained two rosters (session-control `jobs` frames and the activity control stream) and joined them per row. Measured before the merge: eight pairing call sites, and one producer population — background bash/pwsh (both registries), PTY sends and subagent delegations (jobs only), foreground workflow (activity only).
The one activity without a job was the foreground workflow mirror. Everything else the split enabled — independent observers, absolute-offset reads, bounded retention — is a property of the record, not of the registry split.
## Decision
`ctx.jobs` owns the observation record; `packages/activity/`, `packages/api/activity-controller`, and the correlation vocabulary are removed.
- **`JobStart.record?: true`** declares an observable output record. `run(job)` now receives the job's producer face — `RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` — so the id is issued before the starter runs (a throwing starter still registers nothing; its ordinal is skipped). Writes staged inside the starter surface at the registration commit. `updateDetail` works for every job and gives `job_list` a live progress line; `append` without a record declaration logs and drops.
- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented.
- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design.
- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it.
- **The wire moves to the session namespace.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable; `session.observeJob({sessionId?, jobId, from?})` streams anchor/output/status frames with the fenced read resolved from the request's session — the killJob authorization shape. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery moved into `session-controller` (`observe-job.ts`, client `ctx.jobOutput`).
- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `outputTotal` is present. The two-roster join is deleted.
- **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows.
## Alternatives considered
- **A `foreground: true` job mode** to keep the foreground workflow panel: it needs two coupled enforcement points (reported-at-birth and a `job_list` filter) whose divergence double-delivers or leaks rows to the model, for one edge feature replaceable by `run_in_background`.
- **Serving model reads from the record** (deleting `readOutput`): model reads are producer-formatted (truncation and spill notices, sandbox markers) and consuming; the record is raw, channel-labeled, and non-consuming. Unifying them moves producer-specific formatting into the registry or noise into the observer stream.
- **Keeping the split with shared implementation** (a common registry library): it removes the duplicated skeleton but keeps the real costs — double registration, terminal-state pairing, correlation, a second wire roster, and a second package family.
The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argument against durable session events and control-stream framing for live output still holds and carries over unchanged: the record is process-local observation state, never a session event, so "model-visible ⟺ logged" is untouched.
## Consequences
Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `session-controller`.
@@ -0,0 +1,35 @@
# Agent Note:job 注册表吸收观测 record;独立的 activity seam 被移除
Status: implemented
[English](2026-09-01-jobs-absorb-activity-record.md) | 中文
## 问题
[activity 观测 seam](../feature/2026-08-24-activity-observation-seam.zh.md) 把实时输出流做成了 `ctx.jobs` 旁边的第二个注册表:生产者要把同一份工作登记两次(job 管生命周期,activity 管观测),手工保持两个终态一致(`observeBackgroundActivity` 要等 `proc.done` 才映射 outcome,防止 pump 失败把错误终态冻上去),通过 `ActivityCorrelation.jobId` 关联两行,并给每个观测调用包上注册表缺失的降级分支。Web 客户端维护两份名册(session-control 的 `jobs` 帧与 activity control 流)并逐行做 join。合并前实测:八个配对调用点;生产者人口为——后台 bash/pwsh(双注册表)、PTY 发送与 subagent 委托(仅 jobs)、前台 workflow(仅 activity)。
唯一没有 job 的 activity 是前台 workflow 镜像。拆分带来的其他一切——独立观察者、绝对偏移读取、有界保留——都是 record 的性质,不是注册表拆分的性质。
## 决策
`ctx.jobs` 拥有观测 record;`packages/activity/`、`packages/api/activity-controller` 与 correlation 词表被移除。
- **`JobStart.record?: true`** 声明可观测输出 record。`run(job)` 现在收到 job 的生产者面——`RunningJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`——因此 id 在 starter 运行前签发(starter 抛出仍然不登记任何东西;序号被跳过)。starter 内暂存的写入在登记提交时可见。`updateDetail` 对所有 job 可用,让 `job_list` 也能看到实时进度行;未声明 record 的 `append` 记日志并丢弃。
- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。
- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。
- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。
- **wire 移入 session 命名空间。** `SessionJob.outputTotal`(恰在 record job 上存在)标记行可观测;`session.observeJob({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析——与 killJob 相同的授权形态。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制移入 `session-controller`(`observe-job.ts`,客户端 `ctx.jobOutput`)。
- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。
- **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。
## 曾考虑的替代方案
- **`foreground: true` job 模式**以保留前台 workflow 面板:它需要两个耦合的执行点(出生即 reported 与 `job_list` 过滤),两者一旦分叉就会双投递或向模型泄漏行,而它换来的只是一个可被 `run_in_background` 替代的边缘特性。
- **用 record 服务模型读取**(删除 `readOutput`):模型读取是生产者格式化的(截断与 spill 提示、sandbox 标记)且消耗型;record 是原始、带 channel 标签、非消耗的。统一二者要么把生产者特有格式化搬进注册表,要么把噪声混入观察者流。
- **保留拆分但共享实现**(公共注册表库):它消除重复骨架,但保留真正的成本——双重登记、终态配对、correlation、第二条 wire 名册与第二个包族。
[先前 seam note](../feature/2026-08-24-activity-observation-seam.zh.md) 反对用持久会话事件或 control 流承载实时输出的论证仍然成立并原样沿用:record 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。
## 后果
每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `session-controller`。
@@ -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-08-web-background-job-display.md
2026-08-08-web-background-job-display.md: 218bef290094856112dc64cae66c4b3829980029
2026-08-08-web-background-job-display.zh.md: 6aad4daad53a1ca9863df23305c4ca5e935b73e5
2026-08-08-web-background-job-display.md: 82be7df313215ec1b587c5730bc6fae1db2c2a40
2026-08-08-web-background-job-display.zh.md: 0bfd8e83242123e82e2caad40bbd0485ce77c7f7
@@ -2,6 +2,8 @@
Status: implemented
Update: the output phase this note deferred now ships as the per-job observation record on `ctx.jobs` — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md).
English | [中文](2026-08-08-web-background-job-display.zh.md)
## Problem
@@ -2,6 +2,8 @@
Status: implemented
Update:本文推迟的输出阶段现已作为 `ctx.jobs` 上的逐 job 观测 record 交付——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。
[English](2026-08-08-web-background-job-display.md) | 中文
## 问题
@@ -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-24-activity-observation-seam.md
2026-08-24-activity-observation-seam.md: 88109cb3404132885bde359af0907a29ea1bee2c
2026-08-24-activity-observation-seam.zh.md: b8aa938edfd0019794211975c616aa50381ffe77
2026-08-24-activity-observation-seam.md: 29ee08b1bb7f72a63d2f84278af62587060de3fe
2026-08-24-activity-observation-seam.zh.md: 97ac4f195c83cdd4f16d2f0a108abd2b3338d95d
@@ -2,6 +2,8 @@
Status: implemented
Superseded: the standalone seam described here was folded into `ctx.jobs` as the per-job observation record — see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md); the durable-vs-live analysis below still holds.
English | [中文](2026-08-24-activity-observation-seam.zh.md)
## Problem
@@ -2,6 +2,8 @@
状态:implemented
Superseded:本文描述的独立 seam 已折入 `ctx.jobs`,成为逐 job 的观测 record——见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md);下文关于持久化与实时通道的分析仍然成立。
[English](2026-08-24-activity-observation-seam.md) | 中文
## 问题
@@ -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-25-unified-task-list.md
2026-08-25-unified-task-list.md: e4ba43e9e599df7f5d7c4557986fbe0e693bedc3
2026-08-25-unified-task-list.zh.md: fde7acbec5ad037722b00551b0e75fc1557dc705
2026-08-25-unified-task-list.md: 066f63550e19d03edeae1655eb17a0d6086ff388
2026-08-25-unified-task-list.zh.md: 8c63d076fb53da4e5eb00b94268619966a66e7c3
@@ -2,6 +2,8 @@
Status: implemented
Superseded: the two-roster join described here was removed with the activity seam — the job rows are now the whole roster; see [jobs absorb the record](../architecture/2026-09-01-jobs-absorb-activity-record.md).
English | [中文](2026-08-25-unified-task-list.zh.md)
## Problem
@@ -2,6 +2,8 @@
状态:implemented
Superseded:本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。
[English](2026-08-25-unified-task-list.md) | 中文
## 问题
-3
View File
@@ -40,8 +40,6 @@ flowchart LR
cfg --> plugin_dsh_base_agent_default_model
plugin_dsh_base_jobs["jobs<br/>@deepseek-ai/dsh-jobs-local"]
cfg --> plugin_dsh_base_jobs
plugin_dsh_base_activity["activity<br/>@deepseek-ai/dsh-activity-local"]
cfg --> plugin_dsh_base_activity
plugin_dsh_base_llm_retry["llm-retry<br/>@deepseek-ai/dsh-llm-retry"]
cfg --> plugin_dsh_base_llm_retry
plugin_dsh_base_settings["settings<br/>@deepseek-ai/dsh-settings-file"]
@@ -202,7 +200,6 @@ flowchart LR
| `plugin-package-inventory-deepseek` | `@deepseek-ai/dsh-plugin-package-inventory-deepseek` |
| `agent-default-model` | `@deepseek-ai/dsh-agent-default-model` |
| `jobs` | `@deepseek-ai/dsh-jobs-local` |
| `activity` | `@deepseek-ai/dsh-activity-local` |
| `llm-retry` | `@deepseek-ai/dsh-llm-retry` |
| `settings` | `@deepseek-ai/dsh-settings-file` |
| `credentials` | `@deepseek-ai/dsh-credentials-local` |
+5 -5
View File
@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
it('shows a running background job in the session header without a refresh', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running'))
// Polling for zero would pass at t=0 before delivery and prove nothing.
const trigger = page.getByRole('button', { name: '1 task running' })
const trigger = page.getByRole('button', { name: '1 background job running' })
expect(await trigger.count()).toBe(0)
const started = await scaffold.ctx.tools.execute({
@@ -97,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
const row = page.getByRole('list', { name: 'Tasks' }).getByRole('listitem').first()
const row = page.getByRole('list', { name: 'Background jobs' }).getByRole('listitem').first()
await row.waitFor({ timeout: 10_000 })
await expect.poll(() => row.textContent()).toContain(COMMAND)
@@ -117,12 +117,12 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await expect.poll(() => stop.getAttribute('data-kill-state')).toBe('armed')
await stop.click()
// Exact: the running trigger's name ('1 task running') contains this label.
const idle = page.getByRole('button', { name: '1 task', exact: true })
// Exact: the running trigger's name ('1 background job running') contains this label.
const idle = page.getByRole('button', { name: '1 background job', exact: true })
await idle.waitFor({ timeout: 20_000 })
// The unclaimed report's reason lands in the settled row's detail.
await expect.poll(
() => page.getByRole('list', { name: 'Tasks' }).textContent(),
() => page.getByRole('list', { name: 'Background jobs' }).textContent(),
{ timeout: 15_000 },
).toContain('cancelled by the user')
expect(scaffold.ctx.jobs.get(jobId, agent).status).toBe('killed')
@@ -1,5 +1,5 @@
// Session-header live activities driven by a real background bash run: the
// roster row arrives over the activity control stream, expanding it opens the
// Session-header job list driven by a real background bash run: the job row
// arrives over the session control stream, expanding it opens the record
// observation stream, and the panel shows the process's real output while it
// is still running. No model call is involved.
import { readFile } from 'node:fs/promises'
@@ -19,11 +19,11 @@ import {
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-activity-stream', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/live-job-stream', import.meta.url))
const STREAMING_EXPECTED = join(SNAPSHOT_DIR, 'streaming.expected.md')
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'live-activity-stream-web-e2e'
const SEED_ID = 'live-job-stream-web-e2e'
// One fixed line of early output, then silence long enough that the streaming
// assertions never race the process exiting; the test kills it explicitly.
const COMMAND = "printf 'streamed-%s\\n' marker-line; sleep 45"
@@ -44,7 +44,7 @@ async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<A
}
}
describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => {
describe.skipIf(MODE === 'record')('web e2e: live job stream', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
@@ -69,7 +69,7 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => {
await sessionRow.click()
// Opening the session drives the Host's ordinary Agent resolution; the
// activity owner must be that exact live instance, never a second one.
// job owner must be that exact live instance, never a second one.
agent = await liveAgent(scaffold, SessionId(SEED_ID))
}, 120_000)
@@ -79,13 +79,13 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => {
})
it('streams a running background command\'s real output into the expanded panel', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-streaming'))
const trigger = page.getByRole('button', { name: '1 task running' })
onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-streaming'))
const trigger = page.getByRole('button', { name: '1 background job running' })
expect(await trigger.count()).toBe(0)
const started = await scaffold.ctx.tools.execute({
signal: new AbortController().signal,
callId: ToolCallId('live-activity-stream-e2e'),
callId: ToolCallId('live-job-stream-e2e'),
name: 'bash',
arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true },
agent,
@@ -95,11 +95,11 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => {
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
jobId = JobId(matched[0])
// The roster row reaches the header over the activity control stream.
// The job row reaches the header over the session control stream.
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
// Expanding the row opens the observation stream; the panel then renders
// Expanding the row opens the record observation stream; the panel then renders
// the command's real stdout while the process is still running.
const expand = page.getByRole('button', { name: /Show live output/ })
await expand.waitFor({ timeout: 10_000 })
@@ -113,12 +113,12 @@ describe.skipIf(MODE === 'record')('web e2e: live activity stream', () => {
}, 60_000)
it('settles the open panel when the job is killed, keeping the streamed tail', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-live-activity-settled'))
onTestFailed(() => saveFailureShot(page, 'web-e2e-live-job-settled'))
expect(scaffold.ctx.jobs.kill(jobId, agent, { reason: 'web e2e cancellation' })).toBe('requested')
// Settlement arrives on the observation stream itself, so the open panel
// flips without any further interaction; the roster trigger follows.
const idle = page.getByRole('button', { name: '1 task' })
const idle = page.getByRole('button', { name: '1 background job' })
await idle.waitFor({ timeout: 20_000 })
await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 })
@@ -1,4 +1,4 @@
- list "Tasks":
- list "Background jobs":
- listitem:
- button "Finished 1" [expanded]:
- img
@@ -1,4 +1,4 @@
- list "Tasks":
- list "Background jobs":
- listitem:
- button "Hide live output of printf 'streamed-%s\\n' marker-line; sleep 45" [expanded]:
- text: printf 'streamed-%s\n' marker-line; sleep 45 bash {{duration}}
+1 -1
View File
@@ -95,7 +95,7 @@
"tests/subagent-interrupt-ui.e2e.ts",
"tests/sidebar-subagent-activity.e2e.ts",
"tests/background-job-list.e2e.ts",
"tests/live-activity-stream.e2e.ts",
"tests/live-job-stream.e2e.ts",
"tests/bash-abort-row.e2e.ts",
"tests/skill-tool-row.e2e.ts",
"tests/turn-tail-actions.e2e.ts",
+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/architecture.md
architecture.md: 8a2f9b772c6c50aed0389421fd26a713668b1907
architecture.zh.md: cc291fb0184bcc62a283741ff453e62d7400eb0a
architecture.md: f36c956d9a14f6b4490d2cd5ce03d807da4a94ad
architecture.zh.md: 67e2ae905cdf0ddb210f320fd7b8cb34395d99d4
+1 -1
View File
@@ -129,7 +129,7 @@ New behavior attaches to a documented extension point. Changing the loop itself
| Add persistent terminal execution | register a `ctx.terminals` backend plus `dsh-tool-terminal` |
| Add a human command | register on `ctx.commands`; it dispatches without a model turn |
| Add background work | register on `ctx.jobs`; `job_*` tools collect or stop it |
| Stream live output to observers | open a `ctx.activities` handle; the Web client renders it |
| Stream live output to observers | declare `record: true` on the `ctx.jobs` start; the Web client renders it |
| Start a Session from an external webhook | register a trusted rule on `ctx.webhookRuntime` and mount a provider adapter |
| Add filesystem access or policy | register a `ctx.fs` provider or listen to `fs/*` events |
| Confine spawned processes | use a `ctx.sandbox` backend; consumers wrap argv before spawning |
+1 -1
View File
@@ -133,7 +133,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统
| 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` |
| 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 |
| 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 |
| 向观察者流式输出 | 打开一个 `ctx.activities` 句柄;Web 客户端负责渲染 |
| 向观察者流式输出 | 在 `ctx.jobs` 启动时声明 `record: true`;Web 客户端负责渲染 |
| 从外部 webhook 启动 Session | 在 `ctx.webhookRuntime` 上注册可信规则,并挂载提供方适配器 |
| 添加文件系统访问或策略 | 注册 `ctx.fs` 提供方,或监听 `fs/*` 事件 |
| 限制所启动的进程 | 使用 `ctx.sandbox` 后端;消费方在启动进程前包装 argv |
+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: 6c0867eeb8d47fefcbbf270e79123935cdae2aa3
capability-seams.zh.md: aa741f96303e0dbf5f883c2fb4a2af927e165f63
capability-seams.md: db2b8db95647204b4a49a63c6a7f1d97b942e635
capability-seams.zh.md: f145241760737654c39662c76ddd027cf22bc409
+3 -17
View File
@@ -45,8 +45,6 @@ flowchart LR
pkg_api_workspace_controller["api-workspace-controller"]
svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
pkg_api_activity_controller["api-activity-controller"]
svc_activityController["ctx.activityController<br/>Host Activity Remote controller"]
svc_invariants["ctx.invariants<br/>Package-owned invariant registry"]
pkg_scope["scope"]
pkg_typert_registry["typert-registry"]
@@ -186,11 +184,6 @@ flowchart LR
svc_jobs["ctx.jobs<br/>Background job registry"]
pkg_jobs_local["jobs-local"]
pkg_tool_jobs["tool-jobs"]
pkg_activity["activity"]
svc_activities["ctx.activities<br/>Live activity observation registry"]
pkg_activity_local["activity-local"]
pkg_tool_workflow["tool-workflow"]
pkg_activity_controller["activity-controller"]
pkg_web["web"]
svc_web["ctx.web<br/>Web access provider registry"]
pkg_web_search_exa["web-search-exa"]
@@ -214,6 +207,7 @@ flowchart LR
pkg_workflow["workflow"]
svc_workflowEngine["ctx.workflowEngine<br/>Workflow script engine"]
pkg_workflow_worker_thread["workflow-worker-thread"]
pkg_tool_workflow["tool-workflow"]
pkg_webhook["webhook"]
svc_webhookRuntime["ctx.webhookRuntime<br/>Webhook rule runtime"]
pkg_webhook_github["webhook-github"]
@@ -223,13 +217,10 @@ flowchart LR
pkg_cordis_host_runner["cordis-host-runner"]
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_activity --> svc_activities
pkg_activity_local --> svc_activities
pkg_agent --> svc_agents
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_presets --> svc_agentPresets
pkg_api_activity_controller --> svc_activityController
pkg_api_gateway --> svc_typertGateway
pkg_api_session_controller --> svc_sessionController
pkg_api_session_controller --> svc_sessionFileReferences
@@ -342,10 +333,6 @@ flowchart LR
pkg_workflow --> svc_workflowEngine
pkg_workflow_worker_thread --> svc_workflowEngine
pkg_workspace --> svc_workspaceRegistry
svc_activities --> pkg_activity_controller
svc_activities --> pkg_tool_bash
svc_activities --> pkg_tool_pwsh
svc_activities --> pkg_tool_workflow
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_agent_spine_demo
@@ -382,6 +369,7 @@ flowchart LR
svc_invariants --> pkg_session
svc_jobs --> pkg_tool_bash
svc_jobs --> pkg_tool_jobs
svc_jobs --> pkg_tool_pwsh
svc_jobs --> pkg_tool_subagent
svc_jobs --> pkg_tool_terminal
svc_llm --> pkg_agent_loop
@@ -489,7 +477,6 @@ flowchart LR
| `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the user-settings seam onto the generated Remote namespace: the read is always redacted and every refusal is classified here, not on the seam Definition. |
| `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. |
| `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Carries the picking seam onto the wire: capability gating, cancellation, and the seam-coded failures a browser directory flow discriminates on. |
| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | Owns the live-activity roster stream and per-activity non-consuming output observation through the generated Remote namespace. |
| `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. |
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. |
| `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. |
@@ -535,8 +522,7 @@ flowchart LR
| `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.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.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-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash, PTY sends, and subagent delegations) register running work; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | Producers mirror live output and status into non-consuming offset-addressed streams; activity-controller serves them to browsers, and the model-facing paths (jobs cursors, tool results) stay untouched. |
| `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) | - | 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. |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | The backend saves oversized tool text and returns a model-facing locator plus retrieval hint; spill-policy is the tools/post-execute consumer that decides when to spill. |
| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | Discriminated interaction capability: the native backend opens one OS chooser on the host display, the browse backend serves listing/creation primitives for the in-app browser; dual-face backends fill ui-workspace directory-flow slots from their browser halves (no wire advertisement). |
+3 -17
View File
@@ -47,8 +47,6 @@ flowchart LR
pkg_api_workspace_controller["api-workspace-controller"]
svc_workspaceController["ctx.workspaceController<br/>Host Workspace Remote controller"]
svc_directoryPickerController["ctx.directoryPickerController<br/>Host directory-picking Remote controller"]
pkg_api_activity_controller["api-activity-controller"]
svc_activityController["ctx.activityController<br/>Host Activity Remote controller"]
svc_invariants["ctx.invariants<br/>Package-owned invariant registry"]
pkg_scope["scope"]
pkg_typert_registry["typert-registry"]
@@ -188,11 +186,6 @@ flowchart LR
svc_jobs["ctx.jobs<br/>Background job registry"]
pkg_jobs_local["jobs-local"]
pkg_tool_jobs["tool-jobs"]
pkg_activity["activity"]
svc_activities["ctx.activities<br/>Live activity observation registry"]
pkg_activity_local["activity-local"]
pkg_tool_workflow["tool-workflow"]
pkg_activity_controller["activity-controller"]
pkg_web["web"]
svc_web["ctx.web<br/>Web access provider registry"]
pkg_web_search_exa["web-search-exa"]
@@ -216,6 +209,7 @@ flowchart LR
pkg_workflow["workflow"]
svc_workflowEngine["ctx.workflowEngine<br/>Workflow script engine"]
pkg_workflow_worker_thread["workflow-worker-thread"]
pkg_tool_workflow["tool-workflow"]
pkg_webhook["webhook"]
svc_webhookRuntime["ctx.webhookRuntime<br/>Webhook rule runtime"]
pkg_webhook_github["webhook-github"]
@@ -225,13 +219,10 @@ flowchart LR
pkg_cordis_host_runner["cordis-host-runner"]
svc_dynamicCordisRunner["ctx.dynamicCordisRunner<br/>Dynamic Cordis package host runner"]
svc_cordisInspect["ctx.cordisInspect<br/>Dynamic Cordis inspect registry"]
pkg_activity --> svc_activities
pkg_activity_local --> svc_activities
pkg_agent --> svc_agents
pkg_agent_default_model --> svc_agentDefaultModel
pkg_agent_loop --> svc_agentLoop
pkg_agent_presets --> svc_agentPresets
pkg_api_activity_controller --> svc_activityController
pkg_api_gateway --> svc_typertGateway
pkg_api_session_controller --> svc_sessionController
pkg_api_session_controller --> svc_sessionFileReferences
@@ -344,10 +335,6 @@ flowchart LR
pkg_workflow --> svc_workflowEngine
pkg_workflow_worker_thread --> svc_workflowEngine
pkg_workspace --> svc_workspaceRegistry
svc_activities --> pkg_activity_controller
svc_activities --> pkg_tool_bash
svc_activities --> pkg_tool_pwsh
svc_activities --> pkg_tool_workflow
svc_agentDefaultModel --> pkg_api_session_controller
svc_agentDefaultModel --> pkg_headless
svc_agentLoop --> pkg_agent_spine_demo
@@ -384,6 +371,7 @@ flowchart LR
svc_invariants --> pkg_session
svc_jobs --> pkg_tool_bash
svc_jobs --> pkg_tool_jobs
svc_jobs --> pkg_tool_pwsh
svc_jobs --> pkg_tool_subagent
svc_jobs --> pkg_tool_terminal
svc_llm --> pkg_agent_loop
@@ -491,7 +479,6 @@ flowchart LR
| `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 |
| `ctx.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 |
| `ctx.directoryPickerController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 把选目录 seam 送上线:能力门禁、取消传播,以及浏览器目录流程用于分支判断的 seam 错误码。 |
| `ctx.activityController` | `core` | [`api-activity-controller`](../packages/api/activity-controller) | - | - | - | 通过生成的 Remote namespace 负责实时活动 roster 流与逐活动的非消费输出观察。 |
| `ctx.invariants` | `core` | [`invariants`](../packages/runtime-diagnostics/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 |
| `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 |
| `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 |
@@ -537,8 +524,7 @@ flowchart LR
| `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.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.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-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash、PTY 发送和 subagent 委派)登记正在运行的工作;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
| `ctx.activities` | `seam` | [`activity`](../packages/activity/activity) | [`activity-local`](../packages/activity/activity-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-workflow`](../packages/workflow/tool-workflow), `activity-controller` | - | 生产者把实时输出与状态镜像进非消费的偏移寻址流;activity-controller 把它们提供给浏览器,模型可见路径(jobs 游标、工具结果)保持不动。 |
| `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) | - | 生产方(后台 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 负责稳定的面向模型名称。 |
| `ctx.spillStore` | `seam` | [`spill`](../packages/spill/spill) | [`spill-local`](../packages/spill/spill-local) | [`spill-policy`](../packages/spill/spill-policy) | - | 后端保存过大的工具文本,并返回面向模型的定位信息和取回提示;spill-policy 是 tools/post-execute 消费方,负责决定何时 spill。 |
| `ctx.directoryPicker` | `seam` | [`host-directory-picker`](../packages/host/directory-picker) | [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | [`api-workspace-controller`](../packages/api/workspace-controller) | - | 带判别标记的交互能力:原生后端在 Host 显示设备上打开一个操作系统选择器,浏览后端为应用内浏览器提供列表与创建原语;双端后端通过其浏览器侧填充 ui-workspace 目录流程的 slot(不通过协议发布)。 |
+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: fdb714538c00263d6bc3bca20b551ef0ca17984a
config-catalog.zh.md: 65e84f86fe7c3c12d33a68b8f12e93206f896f58
config-catalog.md: 64b72e0ff774bd7ecd6142a1ee1f0663e28194b7
config-catalog.zh.md: ab2c0aac8f341e5855299236787421f4b60d357a
+18 -59
View File
@@ -33,22 +33,6 @@ Depends on: `Stream` (`@agentclientprotocol/sdk`)
Source: [`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts)
<a id="deepseek-aidsh-activity-local"></a>
## `@deepseek-ai/dsh-activity-local`
```ts config-catalog
/** Configuration for the process-local observation registry. */
export interface Config {
/** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
/** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */
settledRetainBytes?: number
}
```
Source: [`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts)
<a id="deepseek-aidsh-agent-default-model"></a>
## `@deepseek-ai/dsh-agent-default-model`
@@ -292,24 +276,6 @@ Depends on: [`ToolPresentationMode`](subsystems/tools.md)
Source: [`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
<a id="deepseek-aidsh-api-activity-controller"></a>
## `@deepseek-ai/dsh-api-activity-controller`
Requires: `typert`
```ts config-catalog
/** Configuration for the Activity Remote owner. */
export interface Config {
/** Coalescing window after new output before an observation read, in milliseconds (default 100). */
flushMs?: number
/** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */
maxFrameBytes?: number
}
```
Source: [`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts)
<a id="deepseek-aidsh-api-gateway"></a>
## `@deepseek-ai/dsh-api-gateway`
@@ -339,10 +305,14 @@ export interface Config {
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
/** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
}
```
Source: [`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts)
Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-api-settings-controller"></a>
@@ -988,10 +958,14 @@ export interface Config {
* omission defaults to 10.
*/
maxConcurrentJobsPerOwner?: number
/** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
/** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */
settledRetainBytes?: number
}
```
Source: [`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts)
Source: [`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts)
<a id="deepseek-aidsh-llm-deepseek"></a>
@@ -2712,19 +2686,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv`
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
/** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */
activityPollMs?: number
/**
* Move a foreground command that reaches its timeout into the background as
* a job instead of killing it (default true). Requires background execution:
* it is inert when `enableRunInBackground` is false or the composition has
* no job registry, and every promotion failure falls back to the timeout kill.
*/
promoteOnTimeout?: boolean
/** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
recordPollMs?: number
}
```
Source: [`packages/shell/tool-bash/src/index.ts:35`](../packages/shell/tool-bash/src/index.ts)
Source: [`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts)
<a id="deepseek-aidsh-tool-bash-persistent"></a>
@@ -2886,19 +2853,12 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv`
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
/** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */
activityPollMs?: number
/**
* Move a foreground command that reaches its timeout into the background as
* a job instead of killing it (default true). Requires background execution:
* it is inert when `enableRunInBackground` is false or the composition has
* no job registry, and every promotion failure falls back to the timeout kill.
*/
promoteOnTimeout?: boolean
/** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
recordPollMs?: number
}
```
Source: [`packages/shell/tool-pwsh/src/index.ts:60`](../packages/shell/tool-pwsh/src/index.ts)
Source: [`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts)
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
@@ -3172,7 +3132,7 @@ export interface Config {
}
```
Source: [`packages/workflow/tool-workflow/src/index.ts:33`](../packages/workflow/tool-workflow/src/index.ts)
Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts)
<a id="deepseek-aidsh-tools"></a>
@@ -3468,7 +3428,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-authorization` — requires `credentials` ([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
- `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — requires `webServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-ui-activity` ([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts))
- `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))
- `@deepseek-ai/dsh-client-ui-approval` ([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts))
- `@deepseek-ai/dsh-client-ui-attachment` ([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts))
@@ -3482,6 +3441,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
- `@deepseek-ai/dsh-client-ui-directory-picker-native` ([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
- `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))
- `@deepseek-ai/dsh-client-ui-input-trigger` ([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts))
- `@deepseek-ai/dsh-client-ui-jobs` ([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
- `@deepseek-ai/dsh-client-ui-message-feedback` ([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts))
- `@deepseek-ai/dsh-client-ui-model-selection` ([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts))
@@ -3542,7 +3502,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
Abstract service classes — a deployment loads a concrete implementation package instead ([capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
- `@deepseek-ai/dsh-activity` — abstract `ActivityRegistry` ([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts))
- `@deepseek-ai/dsh-attachment` — abstract `AttachmentStore` ([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts))
- `@deepseek-ai/dsh-code-runtime` — abstract `CodeRuntime` ([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
- `@deepseek-ai/dsh-compaction` — abstract `CompactionEngine` ([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts))
+17 -58
View File
@@ -35,22 +35,6 @@ export interface AcpConfig {
来源:[`packages/acp/acp/src/index.ts:75`](../packages/acp/acp/src/index.ts)
<a id="deepseek-aidsh-activity-local"></a>
## `@deepseek-ai/dsh-activity-local`
```ts config-catalog
/** Configuration for the process-local observation registry. */
export interface Config {
/** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
/** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */
settledRetainBytes?: number
}
```
来源:[`packages/activity/activity-local/src/index.ts:31`](../packages/activity/activity-local/src/index.ts)
<a id="deepseek-aidsh-agent-default-model"></a>
## `@deepseek-ai/dsh-agent-default-model`
@@ -294,24 +278,6 @@ export interface Config {
来源:[`packages/core/agent-tool-presentation/src/index.ts:38`](../packages/core/agent-tool-presentation/src/index.ts)
<a id="deepseek-aidsh-api-activity-controller"></a>
## `@deepseek-ai/dsh-api-activity-controller`
Requires: `typert`
```ts config-catalog
/** Configuration for the Activity Remote owner. */
export interface Config {
/** Coalescing window after new output before an observation read, in milliseconds (default 100). */
flushMs?: number
/** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */
maxFrameBytes?: number
}
```
来源:[`packages/api/activity-controller/src/index.ts:26`](../packages/api/activity-controller/src/index.ts)
<a id="deepseek-aidsh-api-gateway"></a>
## `@deepseek-ai/dsh-api-gateway`
@@ -341,10 +307,14 @@ export interface Config {
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
/** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
}
```
来源:[`packages/api/session-controller/src/index.ts:69`](../packages/api/session-controller/src/index.ts)
来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-api-settings-controller"></a>
@@ -990,10 +960,14 @@ export interface Config {
* omission defaults to 10.
*/
maxConcurrentJobsPerOwner?: number
/** Live record retention per job in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
/** Record retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384. */
settledRetainBytes?: number
}
```
来源:[`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts)
来源:[`packages/jobs/jobs-local/src/index.ts:38`](../packages/jobs/jobs-local/src/index.ts)
<a id="deepseek-aidsh-llm-deepseek"></a>
@@ -2714,19 +2688,12 @@ export type TokenMeterConfig = Record<string, never>
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
/** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */
activityPollMs?: number
/**
* Move a foreground command that reaches its timeout into the background as
* a job instead of killing it (default true). Requires background execution:
* it is inert when `enableRunInBackground` is false or the composition has
* no job registry, and every promotion failure falls back to the timeout kill.
*/
promoteOnTimeout?: boolean
/** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
recordPollMs?: number
}
```
来源:[`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts)
来源:[`packages/shell/tool-bash/src/index.ts:34`](../packages/shell/tool-bash/src/index.ts)
<a id="deepseek-aidsh-tool-bash-persistent"></a>
@@ -2888,19 +2855,12 @@ export interface Config {
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
enableRunInBackground?: boolean
/** Poll cadence for mirroring background output into `ctx.activities`, in milliseconds (default 150). */
activityPollMs?: number
/**
* Move a foreground command that reaches its timeout into the background as
* a job instead of killing it (default true). Requires background execution:
* it is inert when `enableRunInBackground` is false or the composition has
* no job registry, and every promotion failure falls back to the timeout kill.
*/
promoteOnTimeout?: boolean
/** Poll cadence for copying background output into the job record, in milliseconds (default 150). */
recordPollMs?: number
}
```
来源:[`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts)
来源:[`packages/shell/tool-pwsh/src/index.ts:53`](../packages/shell/tool-pwsh/src/index.ts)
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
@@ -3470,7 +3430,6 @@ export interface Config {
- `@deepseek-ai/dsh-authorization` — 需要 `credentials`([`packages/credentials/authorization/src/index.ts`](../packages/credentials/authorization/src/index.ts))
- `@deepseek-ai/dsh-client-locale`([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
- `@deepseek-ai/dsh-client-modules` — 需要 `webServer` · `loader`([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
- `@deepseek-ai/dsh-client-ui-activity`([`packages/client/ui-activity/src/index.ts`](../packages/client/ui-activity/src/index.ts))
- `@deepseek-ai/dsh-client-ui-agent-preset`([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))
- `@deepseek-ai/dsh-client-ui-approval`([`packages/client/ui-approval/src/index.ts`](../packages/client/ui-approval/src/index.ts))
- `@deepseek-ai/dsh-client-ui-attachment`([`packages/client/ui-attachment/src/index.ts`](../packages/client/ui-attachment/src/index.ts))
@@ -3484,6 +3443,7 @@ export interface Config {
- `@deepseek-ai/dsh-client-ui-directory-picker-native`([`packages/client/ui-directory-picker-native/src/index.ts`](../packages/client/ui-directory-picker-native/src/index.ts))
- `@deepseek-ai/dsh-client-ui-goal`([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))
- `@deepseek-ai/dsh-client-ui-input-trigger`([`packages/client/ui-input-trigger/src/index.ts`](../packages/client/ui-input-trigger/src/index.ts))
- `@deepseek-ai/dsh-client-ui-jobs`([`packages/client/ui-jobs/src/index.ts`](../packages/client/ui-jobs/src/index.ts))
- `@deepseek-ai/dsh-client-ui-layout`([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts))
- `@deepseek-ai/dsh-client-ui-message-feedback`([`packages/client/ui-message-feedback/src/index.ts`](../packages/client/ui-message-feedback/src/index.ts))
- `@deepseek-ai/dsh-client-ui-model-selection`([`packages/client/ui-model-selection/src/index.ts`](../packages/client/ui-model-selection/src/index.ts))
@@ -3544,7 +3504,6 @@ export interface Config {
抽象服务类——部署时应改为加载具体的实现包(参见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md))。
- `@deepseek-ai/dsh-activity` — 抽象 `ActivityRegistry`([`packages/activity/activity/src/index.ts`](../packages/activity/activity/src/index.ts))
- `@deepseek-ai/dsh-attachment` — 抽象 `AttachmentStore`([`packages/attachment/attachment/src/index.ts`](../packages/attachment/attachment/src/index.ts))
- `@deepseek-ai/dsh-code-runtime` — 抽象 `CodeRuntime`([`packages/code-runtime/code-runtime/src/index.ts`](../packages/code-runtime/code-runtime/src/index.ts))
- `@deepseek-ai/dsh-compaction` — 抽象 `CompactionEngine`([`packages/compaction/compaction/src/index.ts`](../packages/compaction/compaction/src/index.ts))
+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/event-producer-consumer.md
event-producer-consumer.md: 281d64efc4a279eb1c843eafce932656e227f8ea
event-producer-consumer.zh.md: f644336b2cf540e28a88e2a6729508586021d46f
event-producer-consumer.md: fc05e2ec6ba8d3d5cfc409a617ee069759c93c08
event-producer-consumer.zh.md: 42e79d5469f2b56e119b34b7dac63b8b590ff145
+7 -7
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:522`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:529`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -69,8 +69,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## Non-harness or undeclared event strings seen in package source
+7 -7
View File
@@ -23,11 +23,11 @@
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:523`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:503`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:509`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:516`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:586`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:566`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:593`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:572`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:579`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `approval/request` | `waterfall` | [`packages/interaction/user-approval/src/types.ts:85`](../packages/interaction/user-approval/src/types.ts) | [`user-approval`](../packages/interaction/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `remotes` |
| `authorization/settled` | `emit` | [`packages/credentials/authorization/src/index.ts:57`](../packages/credentials/authorization/src/index.ts) | [`authorization`](../packages/credentials/authorization) (`events.dispatch`) | [`authorization`](../packages/credentials/authorization) |
| `commands/change` | `emit` | [`packages/interaction/commands/src/types.ts:80`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
@@ -71,8 +71,8 @@
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## 包源码中出现的非 harness 或未声明事件字符串
+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: f1a2f29c9e61567a92fb8b98737446c2258087a9
module-graph.zh.md: 86845aa00035ab18ead49e6f90a2e66c18984028
module-graph.md: 6b69abd956658b5bf17f0b49158ec8f366888d94
module-graph.zh.md: 4af048cc46db9bcf990a702d29970a67ff222949
+5 -36
View File
@@ -107,12 +107,7 @@ flowchart TD
subgraph group_acp["packages/acp"]
pkg_acp["acp"]
end
subgraph group_activity["packages/activity"]
pkg_activity["activity"]
pkg_activity_local["activity-local"]
end
subgraph group_api["packages/api"]
pkg_api_activity_controller["api-activity-controller"]
pkg_api_gateway["api-gateway"]
pkg_api_remotes["api-remotes"]
pkg_api_session_controller["api-session-controller"]
@@ -141,7 +136,6 @@ flowchart TD
pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"]
pkg_client_store["client-store"]
pkg_client_ui_activity["client-ui-activity"]
pkg_client_ui_agent_preset["client-ui-agent-preset"]
pkg_client_ui_approval["client-ui-approval"]
pkg_client_ui_attachment["client-ui-attachment"]
@@ -154,6 +148,7 @@ flowchart TD
pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"]
pkg_client_ui_goal["client-ui-goal"]
pkg_client_ui_input_trigger["client-ui-input-trigger"]
pkg_client_ui_jobs["client-ui-jobs"]
pkg_client_ui_layout["client-ui-layout"]
pkg_client_ui_message_feedback["client-ui-message-feedback"]
pkg_client_ui_model_selection["client-ui-model-selection"]
@@ -727,12 +722,6 @@ flowchart TD
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_activity --> pkg_agent
pkg_activity --> pkg_brand
pkg_activity --> pkg_invariants
pkg_activity --> pkg_jobs
pkg_activity --> pkg_llm
pkg_activity --> pkg_session
pkg_headless --> pkg_agent
pkg_headless --> pkg_agent_default_model
pkg_headless --> pkg_invariants
@@ -873,20 +862,6 @@ flowchart TD
pkg_hooks_codex --> pkg_session_persistence
pkg_hooks_codex --> pkg_session_projection
pkg_hooks_codex --> pkg_tools
pkg_activity_local --> pkg_activity
pkg_activity_local --> pkg_agent
pkg_activity_local --> pkg_invariants
pkg_activity_local --> pkg_scope
pkg_api_activity_controller --> pkg_activity
pkg_api_activity_controller --> pkg_agent
pkg_api_activity_controller --> pkg_api_gateway
pkg_api_activity_controller --> pkg_client_connection
pkg_api_activity_controller --> pkg_client_store
pkg_api_activity_controller --> pkg_invariants
pkg_api_activity_controller --> pkg_jobs
pkg_api_activity_controller --> pkg_llm
pkg_api_activity_controller --> pkg_session
pkg_api_activity_controller --> pkg_typert_protocol
pkg_command_compact --> pkg_commands
pkg_command_compact --> pkg_compaction
pkg_command_compact --> pkg_invariants
@@ -1017,7 +992,6 @@ flowchart TD
pkg_llm_replay --> pkg_invariants
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_tool_workflow --> pkg_activity
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_llm
@@ -1075,7 +1049,6 @@ flowchart TD
pkg_host_plugin_inventory --> pkg_brand
pkg_host_plugin_inventory --> pkg_invariants
pkg_host_plugin_inventory --> pkg_typert_protocol
pkg_tool_bash --> pkg_activity
pkg_tool_bash --> pkg_agent
pkg_tool_bash --> pkg_invariants
pkg_tool_bash --> pkg_jobs
@@ -1087,7 +1060,6 @@ flowchart TD
pkg_tool_bash --> pkg_system_prompt
pkg_tool_bash --> pkg_tools
pkg_tool_bash --> pkg_user_approval
pkg_tool_pwsh --> pkg_activity
pkg_tool_pwsh --> pkg_agent
pkg_tool_pwsh --> pkg_invariants
pkg_tool_pwsh --> pkg_jobs
@@ -1366,7 +1338,6 @@ flowchart TD
| [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — |
| [`client-ui-activity`](../packages/client/ui-activity) | `client` | — |
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — |
| [`client-ui-approval`](../packages/client/ui-approval) | `client` | — |
| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — |
@@ -1379,6 +1350,7 @@ flowchart TD
| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | — |
| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — |
| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | — |
| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — |
| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — |
@@ -1525,7 +1497,6 @@ flowchart TD
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -1548,8 +1519,6 @@ flowchart TD
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`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), [`tools`](../packages/core/tools) |
| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) |
| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -1572,7 +1541,7 @@ flowchart TD
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`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), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
@@ -1581,8 +1550,8 @@ flowchart TD
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
+5 -36
View File
@@ -109,12 +109,7 @@ flowchart TD
subgraph group_acp["packages/acp"]
pkg_acp["acp"]
end
subgraph group_activity["packages/activity"]
pkg_activity["activity"]
pkg_activity_local["activity-local"]
end
subgraph group_api["packages/api"]
pkg_api_activity_controller["api-activity-controller"]
pkg_api_gateway["api-gateway"]
pkg_api_remotes["api-remotes"]
pkg_api_session_controller["api-session-controller"]
@@ -143,7 +138,6 @@ flowchart TD
pkg_client_locale["client-locale"]
pkg_client_modules["client-modules"]
pkg_client_store["client-store"]
pkg_client_ui_activity["client-ui-activity"]
pkg_client_ui_agent_preset["client-ui-agent-preset"]
pkg_client_ui_approval["client-ui-approval"]
pkg_client_ui_attachment["client-ui-attachment"]
@@ -156,6 +150,7 @@ flowchart TD
pkg_client_ui_directory_picker_native["client-ui-directory-picker-native"]
pkg_client_ui_goal["client-ui-goal"]
pkg_client_ui_input_trigger["client-ui-input-trigger"]
pkg_client_ui_jobs["client-ui-jobs"]
pkg_client_ui_layout["client-ui-layout"]
pkg_client_ui_message_feedback["client-ui-message-feedback"]
pkg_client_ui_model_selection["client-ui-model-selection"]
@@ -729,12 +724,6 @@ flowchart TD
pkg_fs_sandbox --> pkg_invariants
pkg_fs_sandbox --> pkg_sandbox
pkg_fs_sandbox --> pkg_sandbox_policy
pkg_activity --> pkg_agent
pkg_activity --> pkg_brand
pkg_activity --> pkg_invariants
pkg_activity --> pkg_jobs
pkg_activity --> pkg_llm
pkg_activity --> pkg_session
pkg_headless --> pkg_agent
pkg_headless --> pkg_agent_default_model
pkg_headless --> pkg_invariants
@@ -875,20 +864,6 @@ flowchart TD
pkg_hooks_codex --> pkg_session_persistence
pkg_hooks_codex --> pkg_session_projection
pkg_hooks_codex --> pkg_tools
pkg_activity_local --> pkg_activity
pkg_activity_local --> pkg_agent
pkg_activity_local --> pkg_invariants
pkg_activity_local --> pkg_scope
pkg_api_activity_controller --> pkg_activity
pkg_api_activity_controller --> pkg_agent
pkg_api_activity_controller --> pkg_api_gateway
pkg_api_activity_controller --> pkg_client_connection
pkg_api_activity_controller --> pkg_client_store
pkg_api_activity_controller --> pkg_invariants
pkg_api_activity_controller --> pkg_jobs
pkg_api_activity_controller --> pkg_llm
pkg_api_activity_controller --> pkg_session
pkg_api_activity_controller --> pkg_typert_protocol
pkg_command_compact --> pkg_commands
pkg_command_compact --> pkg_compaction
pkg_command_compact --> pkg_invariants
@@ -1019,7 +994,6 @@ flowchart TD
pkg_llm_replay --> pkg_invariants
pkg_llm_replay --> pkg_llm
pkg_llm_replay --> pkg_session
pkg_tool_workflow --> pkg_activity
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_llm
@@ -1077,7 +1051,6 @@ flowchart TD
pkg_host_plugin_inventory --> pkg_brand
pkg_host_plugin_inventory --> pkg_invariants
pkg_host_plugin_inventory --> pkg_typert_protocol
pkg_tool_bash --> pkg_activity
pkg_tool_bash --> pkg_agent
pkg_tool_bash --> pkg_invariants
pkg_tool_bash --> pkg_jobs
@@ -1089,7 +1062,6 @@ flowchart TD
pkg_tool_bash --> pkg_system_prompt
pkg_tool_bash --> pkg_tools
pkg_tool_bash --> pkg_user_approval
pkg_tool_pwsh --> pkg_activity
pkg_tool_pwsh --> pkg_agent
pkg_tool_pwsh --> pkg_invariants
pkg_tool_pwsh --> pkg_jobs
@@ -1368,7 +1340,6 @@ flowchart TD
| [`client-locale`](../packages/client/locale) | `client` | — |
| [`client-modules`](../packages/client/modules) | `client` | — |
| [`client-store`](../packages/client/store) | `client` | — |
| [`client-ui-activity`](../packages/client/ui-activity) | `client` | — |
| [`client-ui-agent-preset`](../packages/client/ui-agent-preset) | `client` | — |
| [`client-ui-approval`](../packages/client/ui-approval) | `client` | — |
| [`client-ui-attachment`](../packages/client/ui-attachment) | `client` | — |
@@ -1381,6 +1352,7 @@ flowchart TD
| [`client-ui-directory-picker-native`](../packages/client/ui-directory-picker-native) | `client` | — |
| [`client-ui-goal`](../packages/client/ui-goal) | `client` | — |
| [`client-ui-input-trigger`](../packages/client/ui-input-trigger) | `client` | — |
| [`client-ui-jobs`](../packages/client/ui-jobs) | `client` | — |
| [`client-ui-layout`](../packages/client/ui-layout) | `client` | — |
| [`client-ui-message-feedback`](../packages/client/ui-message-feedback) | `client` | — |
| [`client-ui-model-selection`](../packages/client/ui-model-selection) | `client` | — |
@@ -1527,7 +1499,6 @@ flowchart TD
| [`command-goal`](../packages/goal/command-goal) | `goal` | [`commands`](../packages/interaction/commands), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) |
| [`goal-round-driver`](../packages/goal/goal-round-driver) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/runtime-diagnostics/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) |
| [`activity`](../packages/activity/activity) | `activity` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`compaction`](../packages/compaction/compaction) | `compaction` | [`brand`](../packages/util/brand), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`anonymous-user-id`](../packages/identity/anonymous-user-id), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry) |
@@ -1550,8 +1521,6 @@ flowchart TD
| [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
| [`plan-mode`](../packages/plan/plan-mode) | `plan` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-questions`](../packages/interaction/user-questions) |
| [`hooks-codex`](../packages/hooks/hooks-codex) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`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), [`tools`](../packages/core/tools) |
| [`activity-local`](../packages/activity/activity-local) | `activity` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`scope`](../packages/core/scope) |
| [`api-activity-controller`](../packages/api/activity-controller) | `api` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`api-gateway`](../packages/api/gateway), [`client-connection`](../packages/client/connection), [`client-store`](../packages/client/store), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
| [`command-compact`](../packages/compaction/command-compact) | `compaction` | [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants) |
| [`agent-instructions`](../packages/context/agent-instructions) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`home-paths`](../packages/util/home-paths), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) |
| [`file-reference-local`](../packages/context/file-reference-local) | `context` | [`agent`](../packages/core/agent), [`file-reference`](../packages/context/file-reference), [`invariants`](../packages/runtime-diagnostics/invariants), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
@@ -1574,7 +1543,7 @@ flowchart TD
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`agent-loop-testkit`](../packages/test-support/agent-loop-testkit) | `test-support` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`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), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
| [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`mcp-client`](../packages/mcp/mcp-client), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`token-meter`](../packages/llm/token-meter), [`user-approval`](../packages/interaction/user-approval) |
@@ -1583,8 +1552,8 @@ flowchart TD
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`tool-cordis`](../packages/extensions/tool-cordis) | `extensions` | [`agent`](../packages/core/agent), [`cordis-host-runner`](../packages/extensions/cordis-host-runner), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`host-plugin-inventory`](../packages/host/plugin-inventory) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/runtime-diagnostics/invariants), [`typert-protocol`](../packages/typert/protocol) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`activity`](../packages/activity/activity), [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-bash`](../packages/shell/tool-bash) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`tool-pwsh`](../packages/shell/tool-pwsh) | `shell` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`shell`](../packages/shell/shell), [`shell-env`](../packages/shell/shell-env), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) |
| [`webhook`](../packages/webhook/webhook) | `webhook` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`permission-presets`](../packages/interaction/permission-presets), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`workspace`](../packages/workspace/workspace) |
| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`attachment`](../packages/attachment/attachment), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`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), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-approval`](../packages/interaction/user-approval), [`util-time`](../packages/util/time) |
| [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | `session-query` | [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query) |
+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/README.md
README.md: d75bd368d5976813133bb8f62b1b5295bfb32b7c
README.zh.md: 589a72d81fd4e7d277b31626f896b41dc0ea1eb2
README.md: f3340ebeb426fe9cc5c4570b3c8a3440cc1682ec
README.zh.md: 2e4523f03d1a942d9f93abf34ad2cc8687b413b8
-1
View File
@@ -44,7 +44,6 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` |
| [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
| [jobs.md](jobs.md) | the background-job runtime: branded `JobId`s, the producer contract, consumer views, and `ctx.jobs` service behavior |
| [activity.md](activity.md) | live activity observation: branded `ActivityId`s, the producer handle, non-consuming offset reads, and the wire streams |
| [permission-presets.md](permission-presets.md) | the permission-preset layer: `PresetSpec`/`PresetOption`, the derived `custom` state, the log-only `permission/preset` event |
| [plan.md](plan.md) | plan mode: the log-only `plan/mode` state, pending-selection flush, `PlanModeConfig`, the `exit_plan_mode` review arc |
| [invariants.md](invariants.md) | the runtime-invariant registry: selection `Config`, `InvariantInstaller`/`InvariantFailure`, the empty-companion contract |
-1
View File
@@ -44,7 +44,6 @@
| [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` |
| [workflow.md](workflow.zh.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 |
| [jobs.md](jobs.zh.md) | 后台任务运行时:品牌化 `JobId`、producer 约定、消费方视图和 `ctx.jobs` 服务行为 |
| [activity.md](activity.zh.md) | 实时活动观察:品牌化 `ActivityId`、生产者句柄、非消费 offset 读取与 wire 流 |
| [permission-presets.md](permission-presets.zh.md) | 权限预设层:`PresetSpec`/`PresetOption`、派生的 `custom` 状态、仅记日志的 `permission/preset` 事件 |
| [plan.md](plan.zh.md) | 计划模式:仅记日志的 `plan/mode` 状态、待定选择的冲刷、`PlanModeConfig`、`exit_plan_mode` 审阅流程 |
| [invariants.md](invariants.zh.md) | 运行时不变式注册表:选择配置 `Config`、`InvariantInstaller`/`InvariantFailure`、空配套插件约定 |
-6
View File
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/activity.md
activity.md: 000bdc30be05eb17d670367df3fd214e7d0af7f3
activity.zh.md: 1fc79e9d596351a1f818ea83c15d1b5082689b79
-141
View File
@@ -1,141 +0,0 @@
# Live Activity Observation
English | [中文](activity.zh.md)
Types shared by streaming-output producers, `ctx.activities`, and the observation consumers. The [observation-seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) owns the design; this page records the cross-package vocabulary from [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) and the wire frames from [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts).
An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model. Producers keep their execution resources and their model-facing surfaces (`ctx.jobs` consuming cursors, tool results); observers read non-consumingly at absolute byte offsets, so any number of them coexist with the model's single consuming cursor.
## Ids, kinds, and correlation
`ActivityId` is a [branded id](core.md#branded-ids) generated as `<kind>-N`; visibility relies on owner authorization, not id secrecy. `ActivityKindMap` is merge-extensible (`bash` seeded; `pwsh` and `workflow` merged by their producers). `ActivityCorrelation` links one activity to the `CallId` that started the work and the `JobId` carrying its model-facing control surface, so a consumer attaches the stream to an existing card or row without guessing from labels.
## Producer face
`open(spec)` returns an `ActivityHandle` with synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. Offsets advance by each chunk's UTF-8 byte length; `gapBefore` marks producer-side loss so a discontinuity stays visible. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop rather than throw, so a producer's trailing flush cannot break its own teardown.
`pumpActivityOutput(handle, sources, { pollMs, done })` adapts pull substrates: it copies each non-consuming offset reader (the subprocess `readFrom(fromByte)` family, re-exposed on `ShellProcess.observed`) into the handle at a bounded cadence and drains once more after `done` settles. Push-natural producers (the workflow tool's run mirror) append directly.
## Observation
`read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` and the resume offset `next`, consuming nothing. Offsets stay absolute across eviction: `outputEarliest` on the snapshot names the oldest retained byte, and a read below it is `lossy: true`, never an error. Retention is provider policy ([`dsh-activity-local`](../../packages/activity/activity-local/README.md): `retainBytes` live, trimmed to `settledRetainBytes` at settlement).
Two effect-scoped listener families deliver owner-relative on the jobs-registry terms: `onActivitiesChanged` fires per visible-set change (open, detail, settlement, removal) so consumers re-read whole rows, and `onOutput` signals stream advancement (append or settlement) carrying only the id, so each consumer reads from its own cursor.
## Wire transport
[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.md) exposes the plane to browsers as two Remote streams: `activity.control` (baseline plus whole-bucket roster replacement frames) and `activity.observe` (one `opened` anchor, coalesced `output` frames, then the terminal `status` on the same stream, after which the generation closes). Output frames are transient — durable history stays with the producers' tool results, exactly as the [web job display decision](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md) requires. [`dsh-client-ui-activity`](../../packages/client/ui-activity/README.md) renders the roster in the session header and opens observation streams only while a panel is expanded.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxactivities--activityregistry-abstract-seam"></a>
### `ctx.activities` — `ActivityRegistry` (abstract seam)
Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Implementations must honor these semantics:
- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.
- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.
- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.
- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained.
```ts cordis-catalog
/**
* Validate the spec, attach owner cleanup, and atomically register one
* activity. A rejection leaves no id; after return the producer owns the
* handle and the visible set has changed.
* @param spec - activity identity, owner, and correlation.
* @returns the producer face of the registered activity.
*/
abstract open(spec: ActivityOpen): ActivityHandle
/**
* List caller-owned and unowned activities in registration order without
* exposing another session's labels or output.
* @param caller - reading agent; a non-agent caller sees only unowned activities.
* @returns fresh snapshots.
*/
abstract list(caller?: Agent): ActivitySnapshot[]
/**
* Return a fresh snapshot. Throws for an unknown or foreign activity.
* @param id - activity to look up.
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot
/**
* Read retained output from an absolute byte offset without consuming it.
* Resume by passing a previous read's `next`; a foreign offset inside a
* retained chunk returns that whole chunk (its `at` may precede `from`).
* Throws for an unknown or foreign activity or a negative or non-integer
* offset.
* @param id - activity to read.
* @param from - absolute byte offset to read from (0 for the retained head).
* @param caller - reading agent checked against the owner.
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
*/
abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead
/**
* Register an effect-scoped observer of visible-set changes: opening,
* detail updates, settlement, owner-disposal removal, and the emptying that
* service disposal commits — so an observer re-reads rather than
* accumulating deltas. Listeners are contained and never awaited.
* @param listener - receives the owner whose visible set changed, or
* `undefined` when an unowned activity changed and every caller's set did.
* @returns disposer that unregisters the listener.
*/
abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void
/**
* Register an effect-scoped observer of stream advancement — one signal per
* committed append and one at settlement, carrying only the activity id.
* A consumer schedules a {@link read} from its own cursor; the registry
* never pushes payloads. Listeners are contained and never awaited.
* @param listener - receives the id whose stream advanced.
* @returns disposer that unregisters the listener.
*/
abstract onOutput(listener: ActivityOutputListener): () => void
```
Types: [Agent](core.md)
Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts)
<a id="ctxactivitycontroller--activitycontroller"></a>
### `ctx.activityController` — `ActivityController`
Host service backing the generated `ctx.remote.activity` namespace.
```ts cordis-catalog
/**
* Stream a complete roster baseline followed by whole-bucket replacements.
* @param signal - generation cancellation.
* @returns baseline followed by per-owner roster replacement frames.
*/
@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable<ActivityControlFrame>
/**
* Stream one activity's retained output from an absolute byte offset, then
* its terminal status once settled and drained. Non-consuming: the
* model-facing cursors never observe these reads.
* @param request - target activity and optional resume offset.
* @param signal - generation cancellation.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable<ActivityObserveFrame>
```
Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts)
<!-- END GENERATED cordis-surface -->
-141
View File
@@ -1,141 +0,0 @@
# 实时活动观察
[English](activity.md) | 中文
流式输出生产者、`ctx.activities` 与观察消费者共享的类型。设计归[观察 seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)所有;本页记录 [`packages/activity/activity/src/types.ts`](../../packages/activity/activity/src/types.ts) 的跨包词汇与 [`packages/api/activity-controller/src/types.ts`](../../packages/api/activity-controller/src/types.ts) 的 wire 帧。
**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。注册表是纯观察面——不启动、不取消任何工作,对模型不可见。生产者保留自己的执行资源与模型面(`ctx.jobs` 消费游标、工具结果);观察者按绝对字节 offset 非消费读取,任意数量的观察者可与模型的单一消费游标共存。
## Id、kind 与关联
`ActivityId` 是[品牌化 id](core.zh.md#branded-ids),生成为 `<kind>-N`;可见性靠 owner 授权而非 id 保密。`ActivityKindMap` 可合并扩展(内置 `bash`;`pwsh` 与 `workflow` 由各自生产者合并)。`ActivityCorrelation` 把一个 activity 关联到发起工作的 `CallId` 与承载其模型控制面的 `JobId`,消费者据此把流挂到已有卡片或行上,不必靠 label 猜。
## 生产者面
`open(spec)` 返回 `ActivityHandle`:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)` 与 first-wins 的 `end(outcome)`。offset 按每个 chunk 的 UTF-8 字节长度推进;`gapBefore` 标记生产者侧丢失,让断点保持可见。`end` 之后——包括 teardown 强制 end——`append` 与 `updateDetail` 记日志后丢弃而不抛错,生产者的收尾 flush 不会砸掉自己的 teardown。
`pumpActivityOutput(handle, sources, { pollMs, done })` 适配 pull 型底座:把每个非消费 offset 读取器(subprocess 的 `readFrom(fromByte)` 家族,经 `ShellProcess.observed` 复出)按有界节奏复制进句柄,并在 `done` 结算后再排干一次。push 天然的生产者(workflow 工具的运行镜像)直接 append。
## 观察
`read(id, from, caller?)` 返回与 `[from, total)` 重叠的保留 chunk 及续读 offset `next`,绝不消费。offset 跨淘汰稳定:快照上的 `outputEarliest` 指明最老保留字节,低于它的读取得到 `lossy: true` 而非错误。保留量是 provider 策略([`dsh-activity-local`](../../packages/activity/activity-local/README.zh.md):存活 `retainBytes`,结算时裁到 `settledRetainBytes`)。
两个 effect 范围的监听族按 jobs 注册表条款做 owner 相对投递:`onActivitiesChanged` 在每次可见集变化(开启、detail、结算、移除)时触发,消费者整读行;`onOutput` 发出流推进信号(append 或结算),只带 id,各消费者按自己的游标读取。
## Wire 传输
[`dsh-api-activity-controller`](../../packages/api/activity-controller/README.zh.md) 以两条 Remote 流把观察面暴露给浏览器:`activity.control`(baseline + 整桶 roster 替换帧)与 `activity.observe`(一帧 `opened` 锚点、合并的 `output` 帧、同流的终态 `status`,之后本代关闭)。输出帧是瞬态的——持久历史仍在生产者的工具结果里,与 [web job display 决策](../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md)的要求一致。[`dsh-client-ui-activity`](../../packages/client/ui-activity/README.zh.md) 在会话头部渲染 roster,只在面板展开时打开观察流。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
## Cordis API
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
<a id="ctxactivities--activityregistry-abstract-seam"></a>
### `ctx.activities` — `ActivityRegistry` (abstract seam)
Abstract streaming-output registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.activities` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Implementations must honor these semantics:
- Observation never consumes. Any number of readers hold their own absolute byte offsets; a read changes no cursor and no producer state, so the model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.
- Records outlive producer fibers. Owner disposal ends still-open records and removes them; service disposal ends and clears everything. Neither awaits a producer — the registry owns no execution resource.
- Retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap.
- Listener delivery is owner-relative: a listener registered from an unscoped context — a host composition's own carrier — sees every owner, while one registered under an agent composition's scope sees exactly the agents composed under it. Every listener is contained.
```ts cordis-catalog
/**
* Validate the spec, attach owner cleanup, and atomically register one
* activity. A rejection leaves no id; after return the producer owns the
* handle and the visible set has changed.
* @param spec - activity identity, owner, and correlation.
* @returns the producer face of the registered activity.
*/
abstract open(spec: ActivityOpen): ActivityHandle
/**
* List caller-owned and unowned activities in registration order without
* exposing another session's labels or output.
* @param caller - reading agent; a non-agent caller sees only unowned activities.
* @returns fresh snapshots.
*/
abstract list(caller?: Agent): ActivitySnapshot[]
/**
* Return a fresh snapshot. Throws for an unknown or foreign activity.
* @param id - activity to look up.
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot
/**
* Read retained output from an absolute byte offset without consuming it.
* Resume by passing a previous read's `next`; a foreign offset inside a
* retained chunk returns that whole chunk (its `at` may precede `from`).
* Throws for an unknown or foreign activity or a negative or non-integer
* offset.
* @param id - activity to read.
* @param from - absolute byte offset to read from (0 for the retained head).
* @param caller - reading agent checked against the owner.
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
*/
abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead
/**
* Register an effect-scoped observer of visible-set changes: opening,
* detail updates, settlement, owner-disposal removal, and the emptying that
* service disposal commits — so an observer re-reads rather than
* accumulating deltas. Listeners are contained and never awaited.
* @param listener - receives the owner whose visible set changed, or
* `undefined` when an unowned activity changed and every caller's set did.
* @returns disposer that unregisters the listener.
*/
abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void
/**
* Register an effect-scoped observer of stream advancement — one signal per
* committed append and one at settlement, carrying only the activity id.
* A consumer schedules a {@link read} from its own cursor; the registry
* never pushes payloads. Listeners are contained and never awaited.
* @param listener - receives the id whose stream advanced.
* @returns disposer that unregisters the listener.
*/
abstract onOutput(listener: ActivityOutputListener): () => void
```
Types: [Agent](core.zh.md)
Source: [`packages/activity/activity/src/index.ts`](../../packages/activity/activity/src/index.ts)
<a id="ctxactivitycontroller--activitycontroller"></a>
### `ctx.activityController` — `ActivityController`
Host service backing the generated `ctx.remote.activity` namespace.
```ts cordis-catalog
/**
* Stream a complete roster baseline followed by whole-bucket replacements.
* @param signal - generation cancellation.
* @returns baseline followed by per-owner roster replacement frames.
*/
@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable<ActivityControlFrame>
/**
* Stream one activity's retained output from an absolute byte offset, then
* its terminal status once settled and drained. Non-consuming: the
* model-facing cursors never observe these reads.
* @param request - target activity and optional resume offset.
* @param signal - generation cancellation.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' }) observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable<ActivityObserveFrame>
```
Source: [`packages/api/activity-controller/src/index.ts`](../../packages/api/activity-controller/src/index.ts)
<!-- END GENERATED cordis-surface -->
+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/jobs.md
jobs.md: 5aa33d0433f5f43237353b686a9528d8266de260
jobs.zh.md: 888870464757c3296f188892d17c9c5954ebf8fe
jobs.md: 6334ba745642c6e77dd14e8c2bbe4f535e3e552e
jobs.zh.md: 8e3682a150fe1aacf0cf73d4e802822da81beebb
+131 -10
View File
@@ -23,7 +23,7 @@ interface JobKindMap {
## Producer contract
`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state.
`JobStart` declares identity and a starter. The runtime finishes preflight before calling `run()` with the job's producer face and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, and lifecycle state.
```ts type-equiv
/**
@@ -38,7 +38,9 @@ interface JobStart {
label: string
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including controller status metadata.
* output read, including controller status metadata. Independent of record
* retention: it bounds the consuming model surface, never
* {@link JobRegistry.readRecord}.
*/
outputLimitBytes?: number
/**
@@ -49,11 +51,22 @@ interface JobStart {
*/
owner?: Agent
/**
* Start the work after preflight and synchronously return its hooks. Called
* once; a throw leaves nothing registered, and the producer must clean up any
* partially started resources.
* Declares an observable output record beside the model-facing surfaces:
* {@link RunningJob.append} retains chunks in a bounded ring that any number
* of observers read at absolute byte offsets through
* {@link JobRegistry.readRecord}, and snapshots carry
* {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}.
* Without the declaration `append` logs and drops.
*/
run(): JobHooks
record?: true
/**
* Start the work after preflight and synchronously return its hooks. Called
* once with the job's producer face; a throw leaves nothing registered (the
* spent ordinal is skipped), and the producer must clean up any partially
* started resources.
* @param job - the issued id plus the record append and live-detail writers.
*/
run(job: RunningJob): JobHooks
}
```
@@ -71,13 +84,16 @@ interface JobHooks {
* Resolves after the producer releases its resources, not merely when work
* finishes. Must not reject; the runtime converts a rejection to `failed`.
* If teardown cancellation throws, the runtime may force-fail only the
* registry record without claiming that the work stopped.
* registry record without claiming that the work stopped. Settlement also
* ends the record: fold any record-filling pump into this promise so the
* final drain lands before the registry trims and closes the stream.
*/
done: Promise<JobOutcome>
/**
* Consume output produced since the previous call. The producer formats
* truncation and spill notices. Absence marks a final-output-only job; each
* job has one consuming cursor.
* job has one consuming cursor. Independent of the record: this is the
* model-facing projection, {@link JobRegistry.readRecord} the observer one.
*/
readOutput?(): string
}
@@ -95,6 +111,70 @@ interface JobOutcome {
}
```
## Observation record
A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence.
```ts type-equiv
/**
* Producer face of one registered job, handed to {@link JobStart.run} and
* valid for the job's whole life. All methods are synchronous. Writes staged
* inside the starter call are retained and become visible with the
* registration commit; after settlement — the producer's own outcome, a kill,
* or a registry-forced teardown end — both methods log and drop instead of
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
interface RunningJob {
/** The registry-issued id (`<kind>-N`). */
readonly id: JobId
/**
* Append one record chunk. Offsets advance by the chunk's UTF-8 byte
* length; an empty chunk is dropped without waking observers. Without a
* {@link JobStart.record} declaration the chunk is logged and dropped.
* @param text - the chunk text, exactly as produced.
* @param options - stream label and gap marker.
*/
append(text: string, options?: JobAppendOptions): void
/**
* Replace the snapshot's status detail with a live progress line (`3/10`).
* Works for every job, with or without a record.
* @param detail - the new detail line.
*/
updateDetail(detail: string): void
}
```
```ts type-equiv
/** One retained record chunk returned by {@link JobRegistry.readRecord}. */
interface JobRecordChunk {
/** Absolute offset of the chunk's first byte. */
at: number
/** Chunk text exactly as appended (possibly tail-trimmed by retention). */
text: string
/** Stream label, when the producer supplied one. */
channel?: JobChannel
/** Producer-reported loss immediately before this chunk. */
gapBefore?: true
}
```
```ts type-equiv
/** Result of one non-consuming {@link JobRegistry.readRecord}. */
interface JobRecordRead {
/** Retained chunks overlapping `[from, total)`, in offset order. */
chunks: readonly JobRecordChunk[]
/**
* Offset to resume from — the record's current `outputTotal`. Always a
* chunk boundary: appends land whole and trimming only advances chunk
* starts, and consumers concatenate `chunks` under that assumption, so a
* provider serving partial chunks would silently duplicate text.
*/
next: number
/** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */
lossy: boolean
}
```
## Consumer views
Snapshots are fresh read-only projections. `ownerSession` carries the shared `SessionId` used for authorization; completion listeners separately receive the exact owner object used for lifecycle cleanup. `reported` suppresses a completion notice after another reporter has delivered or committed to deliver the terminal state, including the teardown cancel that drains an owner or the service.
@@ -121,7 +201,10 @@ interface JobSnapshot {
ownerSession?: SessionId
/** Current lifecycle state. */
status: JobStatus
/** Kind-specific status detail, present once the producer supplied one (usually terminal). */
/**
* Kind-specific status detail: live progress while the producer updates it
* through {@link RunningJob.updateDetail}, the terminal detail once settled.
*/
detail?: string
/** Epoch ms when the job was registered. */
startedAt: number
@@ -135,6 +218,17 @@ interface JobSnapshot {
* otherwise spend a model request per teardown layer.
*/
reported: boolean
/**
* Total UTF-8 bytes ever appended to the record — the offset the next chunk
* starts at. Present exactly when the job declared {@link JobStart.record}.
*/
outputTotal?: number
/**
* Offset of the oldest retained record byte. Greater than zero exactly when
* retention dropped the head; a reader starting below it gets a lossy read.
* Present exactly when the job declared {@link JobStart.record}.
*/
outputEarliest?: number
}
```
@@ -154,7 +248,7 @@ interface JobRead {
## Service behavior
The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, failure-isolated `onJobDone` and `onJobsChanged` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer.
The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition specifies atomic `start`, caller-scoped `get` and `list`, `read`, non-consuming `readRecord`, `kill`, bounded `wait`, failure-isolated `onJobDone`, `onJobsChanged`, and `onOutput` listeners, and `attachController`; [`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) is the process-local Service Provider. Authorization compares owner sessions; owner cleanup and admission use the exact registered `Agent` instance. The local provider's positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10` and counts `running` plus `stopping` records per exact owner, with one shared bucket for unowned jobs; terminal producer settlement releases capacity, and `retainBytes` (default 262144) with `settledRetainBytes` (default 16384) bound each declared record's live and settled retention. See [`dsh-jobs`](../../packages/jobs/jobs/README.md) for the Service Definition contract, [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.md) for the registry lifecycle and admission policy, and [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.md) for the model-facing Consumer.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -176,6 +270,8 @@ Implementations must honor these semantics:
- Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.
- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.
- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.
```ts cordis-catalog
/**
@@ -277,6 +373,31 @@ abstract onJobDone(listener: JobDoneListener): () => void
*/
abstract onJobsChanged(listener: JobsChangedListener): () => void
/**
* Read retained record output from an absolute byte offset without consuming
* it. Resume by passing a previous read's `next`; a foreign offset inside a
* retained chunk returns that whole chunk (its `at` may precede `from`).
* Never marks the job reported. Throws for an unknown or foreign job, a job
* without a {@link JobStart.record} declaration, or a negative or
* non-integer offset.
* @param id - job to read.
* @param from - absolute byte offset to read from (0 for the retained head).
* @param caller - reading agent checked against the owner.
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
*/
abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead
/**
* Register an effect-scoped observer of record advancement — one signal per
* committed append and one at settlement, carrying only the job id. A
* consumer schedules a {@link readRecord} from its own cursor; the registry
* never pushes payloads. Delivery is owner-relative on the same terms as
* {@link onJobsChanged}. Listeners are contained and never awaited.
* @param listener - receives the id whose record advanced.
* @returns disposer that unregisters the listener.
*/
abstract onOutput(listener: JobOutputListener): () => void
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
* owners its registering context's scope covers, and {@link start} refuses an
+131 -10
View File
@@ -23,7 +23,7 @@ interface JobKindMap {
## 生产方约定
`JobStart` 声明身份和启动器。运行时会在调用 `run()` 前完成预检,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。
`JobStart` 声明身份和启动器。运行时会在完成预检后携带该 job 的生产者面调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。
```ts type-equiv
/**
@@ -38,7 +38,9 @@ interface JobStart {
label: string
/**
* Optional UTF-8 byte cap for each complete model-facing completion notice or
* output read, including controller status metadata.
* output read, including controller status metadata. Independent of record
* retention: it bounds the consuming model surface, never
* {@link JobRegistry.readRecord}.
*/
outputLimitBytes?: number
/**
@@ -49,11 +51,22 @@ interface JobStart {
*/
owner?: Agent
/**
* Start the work after preflight and synchronously return its hooks. Called
* once; a throw leaves nothing registered, and the producer must clean up any
* partially started resources.
* Declares an observable output record beside the model-facing surfaces:
* {@link RunningJob.append} retains chunks in a bounded ring that any number
* of observers read at absolute byte offsets through
* {@link JobRegistry.readRecord}, and snapshots carry
* {@link JobSnapshot.outputTotal} and {@link JobSnapshot.outputEarliest}.
* Without the declaration `append` logs and drops.
*/
run(): JobHooks
record?: true
/**
* Start the work after preflight and synchronously return its hooks. Called
* once with the job's producer face; a throw leaves nothing registered (the
* spent ordinal is skipped), and the producer must clean up any partially
* started resources.
* @param job - the issued id plus the record append and live-detail writers.
*/
run(job: RunningJob): JobHooks
}
```
@@ -71,13 +84,16 @@ interface JobHooks {
* Resolves after the producer releases its resources, not merely when work
* finishes. Must not reject; the runtime converts a rejection to `failed`.
* If teardown cancellation throws, the runtime may force-fail only the
* registry record without claiming that the work stopped.
* registry record without claiming that the work stopped. Settlement also
* ends the record: fold any record-filling pump into this promise so the
* final drain lands before the registry trims and closes the stream.
*/
done: Promise<JobOutcome>
/**
* Consume output produced since the previous call. The producer formats
* truncation and spill notices. Absence marks a final-output-only job; each
* job has one consuming cursor.
* job has one consuming cursor. Independent of the record: this is the
* model-facing projection, {@link JobRegistry.readRecord} the observer one.
*/
readOutput?(): string
}
@@ -95,6 +111,70 @@ interface JobOutcome {
}
```
## 观测 record
声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。
```ts type-equiv
/**
* Producer face of one registered job, handed to {@link JobStart.run} and
* valid for the job's whole life. All methods are synchronous. Writes staged
* inside the starter call are retained and become visible with the
* registration commit; after settlement — the producer's own outcome, a kill,
* or a registry-forced teardown end — both methods log and drop instead of
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
interface RunningJob {
/** The registry-issued id (`<kind>-N`). */
readonly id: JobId
/**
* Append one record chunk. Offsets advance by the chunk's UTF-8 byte
* length; an empty chunk is dropped without waking observers. Without a
* {@link JobStart.record} declaration the chunk is logged and dropped.
* @param text - the chunk text, exactly as produced.
* @param options - stream label and gap marker.
*/
append(text: string, options?: JobAppendOptions): void
/**
* Replace the snapshot's status detail with a live progress line (`3/10`).
* Works for every job, with or without a record.
* @param detail - the new detail line.
*/
updateDetail(detail: string): void
}
```
```ts type-equiv
/** One retained record chunk returned by {@link JobRegistry.readRecord}. */
interface JobRecordChunk {
/** Absolute offset of the chunk's first byte. */
at: number
/** Chunk text exactly as appended (possibly tail-trimmed by retention). */
text: string
/** Stream label, when the producer supplied one. */
channel?: JobChannel
/** Producer-reported loss immediately before this chunk. */
gapBefore?: true
}
```
```ts type-equiv
/** Result of one non-consuming {@link JobRegistry.readRecord}. */
interface JobRecordRead {
/** Retained chunks overlapping `[from, total)`, in offset order. */
chunks: readonly JobRecordChunk[]
/**
* Offset to resume from — the record's current `outputTotal`. Always a
* chunk boundary: appends land whole and trimming only advances chunk
* starts, and consumers concatenate `chunks` under that assumption, so a
* provider serving partial chunks would silently duplicate text.
*/
next: number
/** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */
lossy: boolean
}
```
## 消费方视图
快照是每次新建的只读投影。`ownerSession` 携带用于授权的共享 `SessionId`;完成监听器则会另行收到用于生命周期清理的确切拥有者对象。另一个接口已经交付终止状态或承诺交付时,`reported` 会抑制完成通知;排空 owner 或服务的 teardown 取消同样计入。
@@ -121,7 +201,10 @@ interface JobSnapshot {
ownerSession?: SessionId
/** Current lifecycle state. */
status: JobStatus
/** Kind-specific status detail, present once the producer supplied one (usually terminal). */
/**
* Kind-specific status detail: live progress while the producer updates it
* through {@link RunningJob.updateDetail}, the terminal detail once settled.
*/
detail?: string
/** Epoch ms when the job was registered. */
startedAt: number
@@ -135,6 +218,17 @@ interface JobSnapshot {
* otherwise spend a model request per teardown layer.
*/
reported: boolean
/**
* Total UTF-8 bytes ever appended to the record — the offset the next chunk
* starts at. Present exactly when the job declared {@link JobStart.record}.
*/
outputTotal?: number
/**
* Offset of the oldest retained record byte. Greater than zero exactly when
* retention dropped the head; a reader starting below it gets a lossy read.
* Present exactly when the job declared {@link JobStart.record}.
*/
outputEarliest?: number
}
```
@@ -154,7 +248,7 @@ interface JobRead {
## 服务行为
抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onJobDone` 与 `onJobsChanged` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是其进程局部 Service Provider。授权会比较拥有者会话;拥有者清理与准入会使用确切的已注册 `Agent` 实例。本地 Service Provider 的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;它按确切 owner 统计 `running` 与 `stopping` 记录,所有无 owner 任务共享一个服务级桶,并在生产方终止结算后释放容量。Service Definition 约定见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md),注册表生命周期与准入策略见 [`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md),面向模型的 Consumer 见 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)。
抽象的 [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Definition 规定了原子化的 `start`、按调用方划定的 `get` 与 `list`、`read`、非消耗的 `readRecord`、`kill`、有界的 `wait`、故障隔离的 `onJobDone`、`onJobsChanged` 与 `onOutput` 监听器,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较 owner 会话;owner 清理与准入使用注册在案的确切 `Agent` 实例。本地 provider 的正安全整数配置 `maxConcurrentJobsPerOwner` 默认 `10`,按确切 owner 统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方终态结算释放容量,`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个已声明 record 的运行期与结算后保留量。参见 [`dsh-jobs`](../../packages/jobs/jobs/README.zh.md)(Service Definition 契约)、[`dsh-jobs-local`](../../packages/jobs/jobs-local/README.zh.md)(注册表生命周期与准入策略)与 [`dsh-tool-jobs`](../../packages/jobs/tool-jobs/README.zh.md)(模型侧 Consumer)。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
@@ -176,6 +270,8 @@ Implementations must honor these semantics:
- Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
- Settlement is first-wins: one terminal record, released waiters, and one round of contained listener notification, even against a late producer outcome. Completion is announced last, after the record is committed and every other observer of the settlement has seen it, because a reporter may open a model turn synchronously.
- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and completion-listener delivery — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
- Record observation never consumes. Any number of readRecord readers hold their own absolute byte offsets; a record read changes no cursor and no notice state, so the model-facing surfaces (the consuming read, completion notices) are unaffected.
- Record retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement — the producer outcome, a kill, or teardown — trims retention to the settled cap and ends the stream; the record has no separate lifecycle.
```ts cordis-catalog
/**
@@ -277,6 +373,31 @@ abstract onJobDone(listener: JobDoneListener): () => void
*/
abstract onJobsChanged(listener: JobsChangedListener): () => void
/**
* Read retained record output from an absolute byte offset without consuming
* it. Resume by passing a previous read's `next`; a foreign offset inside a
* retained chunk returns that whole chunk (its `at` may precede `from`).
* Never marks the job reported. Throws for an unknown or foreign job, a job
* without a {@link JobStart.record} declaration, or a negative or
* non-integer offset.
* @param id - job to read.
* @param from - absolute byte offset to read from (0 for the retained head).
* @param caller - reading agent checked against the owner.
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
*/
abstract readRecord(id: JobId, from: number, caller?: Agent): JobRecordRead
/**
* Register an effect-scoped observer of record advancement — one signal per
* committed append and one at settlement, carrying only the job id. A
* consumer schedules a {@link readRecord} from its own cursor; the registry
* never pushes payloads. Delivery is owner-relative on the same terms as
* {@link onJobsChanged}. Listeners are contained and never awaited.
* @param listener - receives the id whose record advanced.
* @returns disposer that unregisters the listener.
*/
abstract onOutput(listener: JobOutputListener): () => void
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
* owners its registering context's scope covers, and {@link start} refuses an
+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/session.md
session.md: 9aba99e2205a04ac66877034f114030d35eb5b52
session.zh.md: f297cc998f5852607bf294f5b4cff06a87f6078e
session.md: 147b505485bc7baf5b380cc7144c0469db467cde
session.zh.md: d105b23c8e22dad31844e5f7c625f0b7e159f7b9
+12
View File
@@ -748,6 +748,18 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
* @returns one complete baseline followed by live replacement frames.
*/
@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable<SessionControlFrame>
/**
* Stream one job's retained record output from an absolute byte offset,
* then its terminal status once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
* request's session resolves the fenced-read caller; the registry rejects a
* job the session does not own, a job without a record, or an unknown job.
* @param request - target job, owning session, and optional resume offset.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable<SessionObserveJobFrame>
```
Types: [SessionHeader](persistence.md) · [SessionId](core.md) · [SessionSearchRequest](session-query.md)
+12
View File
@@ -752,6 +752,18 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise<{ meta: SessionH
* @returns one complete baseline followed by live replacement frames.
*/
@Remote({ mode: 'stream' }) control(signal: AbortSignal): AsyncIterable<SessionControlFrame>
/**
* Stream one job's retained record output from an absolute byte offset,
* then its terminal status once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
* request's session resolves the fenced-read caller; the registry rejects a
* job the session does not own, a job without a record, or an unknown job.
* @param request - target job, owning session, and optional resume offset.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable<SessionObserveJobFrame>
```
Types: [SessionHeader](persistence.zh.md) · [SessionId](core.zh.md) · [SessionSearchRequest](session-query.zh.md)
-6
View File
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/activity/README.md
README.md: bc9e67703b2888048eacd3a8e060aaa739948eac
README.zh.md: 26f339b8f262b90001355b690f736e2d1089cab5
-43
View File
@@ -1,43 +0,0 @@
---
description: "The activity package group: the non-consuming live-output observation plane under packages/activity/, for readers choosing or navigating the family."
kind: "package-group"
---
# activity/ — streaming-output observation family
English | [中文](README.zh.md)
## Summary
This family lets any number of independent observers watch a long-running producer's live output without touching the model-facing paths: an activity is an append-only bounded output stream plus live status, read at absolute byte offsets that survive eviction. `activity` defines the registry contract and vocabulary; `activity-local` implements the in-memory ring-buffer registry. The plane is optional and invisible to the model — a composition without it loses only live observation.
## Table of Contents
- [Packages](#packages)
- [Related documentation](#related-documentation)
- [Dev Note](#dev-note)
-----
<a id="packages"></a>
## Packages
The Service Definition and its local implementation:
| Package | Role | ctx key |
|---|---|---|
| [`activity/`](activity/README.md) | Defines the observation registry and vocabulary | `ctx.activities` |
| [`activity-local/`](activity-local/README.md) | Implements the in-memory ring-buffer registry | registers on `ctx.activities` |
<a id="related-documentation"></a>
## Related documentation
- [`dsh-api-activity-controller`](../api/activity-controller/README.md) — the Web transport and client model over this plane.
- [`dsh-client-ui-activity`](../client/ui-activity/README.md) — the session-header task list rendering it.
- [docs/subsystems/activity.md](../../docs/subsystems/activity.md) — the subsystem reference: offsets, retention, listener delivery.
- [Activity observation seam Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) — the design decision; background bash/pwsh and the workflow tool are the shipped producers.
<a id="dev-note"></a>
## Dev Note
None.
-43
View File
@@ -1,43 +0,0 @@
---
description: "activity 包组:packages/activity/ 下的非消费实时输出观察面,供选择或导航该家族的读者使用。"
kind: "package-group"
---
# activity/ — 流式输出观察家族
[English](README.md) | 中文
## 概述
这个家族让任意数量的独立观察者在不触碰模型可见路径的前提下观看长时运行生产者的实时输出:一个 activity 是一条只追加、有界的输出流加实时状态,以跨淘汰仍有效的绝对字节偏移读取。`activity` 定义注册表契约与词汇;`activity-local` 实现内存环形缓冲注册表。该观察面是可选的且对模型不可见——没有它的组合只失去实时观察。
## 目录
- [包](#packages)
- [相关文档](#related-documentation)
- [开发备注](#dev-note)
-----
<a id="packages"></a>
## 包
Service Definition 与其本地实现:
| 包 | 角色 | ctx 键 |
|---|---|---|
| [`activity/`](activity/README.zh.md) | 定义观察注册表与词汇 | `ctx.activities` |
| [`activity-local/`](activity-local/README.zh.md) | 实现内存环形缓冲注册表 | 注册在 `ctx.activities` |
<a id="related-documentation"></a>
## 相关文档
- [`dsh-api-activity-controller`](../api/activity-controller/README.zh.md)——架在该面之上的 Web 传输与客户端模型。
- [`dsh-client-ui-activity`](../client/ui-activity/README.zh.md)——渲染它的会话头部任务列表。
- [docs/subsystems/activity.md](../../docs/subsystems/activity.zh.md)——子系统参考:偏移、留存、监听器投递。
- [activity 观察面 Agent Note](../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)——设计决策;后台 bash/pwsh 与 workflow 工具是已交付的生产者。
<a id="dev-note"></a>
## 开发备注
无。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/activity/activity-local/README.md
README.md: ef010e1fe859d2ffd56d736b24e490d73bbb49a5
README.zh.md: 7d9779fc64e7e7d6e47e3a2e78fb80ca097abb6c
@@ -1,98 +0,0 @@
---
description: "The in-memory activity registry: configurable ring-buffer retention (retainBytes, settledRetainBytes), absolute offsets across eviction, owner-scoped cleanup, and layered listener delivery for live-output observation."
kind: "package-reference"
---
# @deepseek-ai/dsh-activity-local
English | [中文](README.zh.md)
## Summary
`dsh-activity-local` makes `ctx.activities` real: mount its one row and every producer's live output is retained in a bounded in-process ring readers address by absolute byte offsets, with fresh snapshots and chunk copies handed out — never live state. Retention is configurable per deployment, records outlive their producer fibers, and owner disposal cleans up exactly the disposed session's rows. Choose it for the single-Host web deployment this repository ships; durable or cross-process observation needs a different provider.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Mount one `cordis.yml` row (`name: '@deepseek-ai/dsh-activity-local'`) beside `dsh-agent`; producers and consumers then reach the registry as `ctx.activities`.
### Retention
Each activity retains a chunk ring whose offsets stay absolute across eviction: dropping the head advances `outputEarliest`, and a single chunk larger than the live cap keeps only its UTF-8-safe tail with a `gapBefore` marker. Settlement trims the ring to the settled cap so a finished activity still shows its tail without holding the live budget.
| Config | Default | Meaning |
|---|---|---|
| `retainBytes` | `262144` | Live output retention per activity, in UTF-8 bytes. |
| `settledRetainBytes` | `16384` | Retention kept after an activity settles. |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-activity-local) is the exhaustive source for every accepted field.
### Lifecycle
Records outlive producer fibers. The first activity for an owner attaches one cleanup to the exact `Agent` scope; owner disposal ends still-open records (`killed`, `owner disposed`) and removes them, announcing the removal. Service disposal ends everything, clears the store, and announces each emptied owner. Neither awaits a producer — the registry owns no execution resource.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
One process-wide `LocalActivityRegistry` keeps a map of tracked records, each holding its chunk ring and cursor facts. Listeners are layered by registration scope exactly like the jobs registry, so delivery stays owner-relative in one instance, and every listener is contained. Trimming walks whole chunks and only tail-cuts a single oversized chunk on a UTF-8 boundary.
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | The registry: rings, retention, fencing, scoped listener layers, disposal |
| [`src/invariant.ts`](src/invariant.ts) | Package invariant companion |
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [`dsh-activity`](../activity/README.md) — the contract this provider implements.
- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — offsets, retention, and delivery in one reference.
- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the Remote streams reading this registry.
-----
<a id="model-experience"></a>
## Model Experience
None, as the provider stores and serves observation state only; the producers own every model-visible surface.
#### KV Cache effect
None; the package never assembles or alters provider requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
These limits define when the provider is a poor fit. They are current package constraints, not a task backlog.
- **The unowned bucket has no admission bound** — owned producers are already bounded by the jobs admission policy, but a plugin opening many unowned activities grows the store until service disposal.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
@@ -1,98 +0,0 @@
---
description: "内存 activity 注册表:可配置的环形缓冲留存(retainBytes、settledRetainBytes)、跨淘汰的绝对偏移、按 owner 作用域的清理,以及实时输出观察的分层监听器投递。"
kind: "package-reference"
---
# @deepseek-ai/dsh-activity-local
[English](README.md) | 中文
## 概述
`dsh-activity-local` 让 `ctx.activities` 落地:挂载它的一行后,每个生产者的实时输出保留在有界的进程内环里,读者以绝对字节偏移寻址,拿到的是新鲜快照与 chunk 拷贝——从不是活状态。留存按部署可配置,记录比生产者 fiber 活得久,owner 释放只清理被释放会话的行。为本仓库交付的单 Host web 部署选择它;持久或跨进程观察需要别的 provider。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
在 `dsh-agent` 旁挂载一行 `cordis.yml`(`name: '@deepseek-ai/dsh-activity-local'`);生产者与消费者随即以 `ctx.activities` 触达注册表。
### 留存
每个 activity 保留一个偏移跨淘汰仍绝对的 chunk 环:丢弃头部会前移 `outputEarliest`,单个超过活跃上限的 chunk 只保留 UTF-8 安全的尾部并带 `gapBefore` 标记。结算把环裁到 settled 上限,让完成的 activity 仍展示尾部而不占活跃预算。
| 配置 | 默认 | 含义 |
|---|---|---|
| `retainBytes` | `262144` | 每个 activity 的活跃输出留存,UTF-8 字节。 |
| `settledRetainBytes` | `16384` | activity 结算后保留的留存量。 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-activity-local)是每个可接受字段的穷尽来源。
### 生命周期
记录比生产者 fiber 活得久。owner 的第一个 activity 在确切的 `Agent` 作用域上挂一个清理;owner 释放会终结仍开放的记录(`killed`、`owner disposed`)并移除它们、announce 该移除。服务释放终结一切、清空存储并 announce 每个被清空的 owner。两者都不等待生产者——注册表不拥有任何执行资源。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内幕——点击展开</summary>
一个进程级 `LocalActivityRegistry` 维护被跟踪记录的 map,每条持有自己的 chunk 环与游标事实。监听器按注册作用域分层,与 jobs 注册表完全一致,投递在单实例内保持 owner 相对,且每个监听器都被包住。裁剪按整 chunk 走,只对单个超大 chunk 在 UTF-8 边界上做尾部截取。
| 文件 | 角色 |
|---|---|
| [`src/index.ts`](src/index.ts) | 注册表:环、留存、栅栏、作用域监听层、释放 |
| [`src/invariant.ts`](src/invariant.ts) | 包不变式伴生 |
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
- [`dsh-activity`](../activity/README.zh.md)——本 provider 实现的契约。
- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——偏移、留存与投递的单一参考。
- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——读取本注册表的 Remote 流。
-----
<a id="model-experience"></a>
## 模型体验
无:provider 只存储与提供观察状态;模型可见表面全部归生产者所有。
#### KV Cache 影响
无;本包从不组装或改动 provider 请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
这些限制说明该 provider 何时不合适。它们是当前包约束,不是任务积压。
- **无主桶没有准入上限**——有主生产者已受 jobs 准入策略约束,但打开大量无主 activity 的插件会让存储一直增长到服务释放。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
</details>
@@ -1,55 +0,0 @@
{
"name": "@deepseek-ai/dsh-activity-local",
"description": "Process-local implementation of the DeepSeek Harness streaming-output observation seam",
"version": "0.1.2-alpha.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/activity/activity-local"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-include": "workspace:^",
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
@@ -1,438 +0,0 @@
/**
* Process-local provider for the streaming-output observation seam
* (`ctx.activities`). It keeps every record and its bounded output ring in
* memory and hands out fresh snapshots and chunk copies, never live state.
*
* Records outlive producer fibers. Owner disposal ends still-open records and
* removes them; service disposal ends and clears everything. Neither awaits a
* producer — the registry owns no execution resource.
* @module @deepseek-ai/dsh-activity-local
*/
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope'
import type { ScopeLayer } from '@deepseek-ai/dsh-scope'
import { ActivityRegistry, ActivityId } from '@deepseek-ai/dsh-activity'
import type {
ActivityAppendOptions, ActivityChannel, ActivityCorrelation, ActivitiesChangedListener,
ActivityHandle, ActivityKind, ActivityOpen, ActivityOutcome, ActivityOutputChunk,
ActivityOutputListener, ActivityRead, ActivitySnapshot, ActivityStatus,
} from '@deepseek-ai/dsh-activity'
/** Default live retention per activity, in UTF-8 bytes. */
const DEFAULT_RETAIN_BYTES = 256 * 1024
/** Default retention kept after settlement, in UTF-8 bytes. */
const DEFAULT_SETTLED_RETAIN_BYTES = 16 * 1024
/** Configuration for the process-local observation registry. */
export interface Config {
/** Live output retention per activity in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
/** Output retention kept after an activity settles, in UTF-8 bytes; omission defaults to 16384. */
settledRetainBytes?: number
}
/** The registry's mutable per-activity record (never handed out — see {@link LocalActivityRegistry.snapshot}). */
interface TrackedActivity {
id: ActivityId
kind: ActivityKind
label: string
/** Exact lifecycle owner; session-id visibility is derived from it. */
owner: Agent | undefined
correlation: ActivityCorrelation | undefined
status: ActivityStatus
detail: string | undefined
startedAt: number
finishedAt: number | undefined
/** Retained chunks in offset order; offsets stay absolute across eviction. */
chunks: RingChunk[]
/** Sum of the retained chunks' byte lengths. */
retainedBytes: number
/** Total UTF-8 bytes ever appended. */
total: number
/** Offset of the oldest retained byte (equals {@link total} when nothing is retained). */
earliest: number
}
/** One retained ring entry; `bytes` caches the chunk's UTF-8 length. */
interface RingChunk {
at: number
text: string
bytes: number
channel?: ActivityChannel
gapBefore?: true
}
/** True for the three terminal {@link ActivityStatus} values. */
function isTerminal(status: ActivityStatus): boolean {
return status === 'completed' || status === 'failed' || status === 'killed'
}
/**
* The UTF-8-safe tail of `text` no longer than `maxBytes`: the byte cut
* advances past continuation bytes so the surviving text never starts inside
* a code point.
* @param text - the oversized chunk text.
* @param maxBytes - positive byte budget for the surviving tail.
* @returns the surviving tail and its exact byte length.
*/
function utf8Tail(text: string, maxBytes: number): { text: string; bytes: number } {
const raw = Buffer.from(text, 'utf8')
let start = raw.length - maxBytes
// The loop bound proves the index is in range; the assertion only
// discharges noUncheckedIndexedAccess.
while (start < raw.length && ((raw[start] as number) & 0xC0) === 0x80) start += 1
const tail = raw.subarray(start)
return { text: tail.toString('utf8'), bytes: tail.length }
}
/**
* One scope's observer contributions. Both tables are anonymous because a
* contribution is identified by its own disposer, never by a name a second
* registrant could shadow.
*/
class ActivityLayer implements ScopeLayer {
readonly changed = new AnonymousEntries<ActivitiesChangedListener>()
readonly output = new AnonymousEntries<ActivityOutputListener>()
isEmpty(): boolean {
return this.changed.isEmpty() && this.output.isEmpty()
}
}
/**
* The in-memory `activities` registry. See the Service Definition contract in
* `@deepseek-ai/dsh-activity` for the observation, retention, and lifecycle
* semantics this implementation honors.
*/
export class LocalActivityRegistry extends ActivityRegistry {
static Config: z<Config> = z.object({
retainBytes: z.number()
.step(1)
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(DEFAULT_RETAIN_BYTES),
settledRetainBytes: z.number()
.step(1)
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(DEFAULT_SETTLED_RETAIN_BYTES),
})
/** Schemastery-defaulted live retention cap. */
private readonly retainBytes: number
/** Schemastery-defaulted settled retention cap. */
private readonly settledRetainBytes: number
private store = new Map<ActivityId, TrackedActivity>()
private counters = new Map<string, number>()
/**
* Observers layered by the scope that registered them, in the jobs-registry
* shape: a contribution files into its registering context's scope, and a
* dispatch unions the global layer with the subject owner's scope chain, so
* delivery stays owner-relative in one process-wide registry. Nothing
* derives a cache from a layer, so change notification is a no-op.
*/
private readonly layers = new ScopedLayers<ActivityLayer>(() => new ActivityLayer(), () => {})
/** Owner agents with attached scope cleanup, mapped to the exact disposer. */
private ownerCleanups = new Map<Agent, () => Promise<void> | void>()
/** Service context used by contained listener reporting and teardown. */
private readonly selfCtx: Context
constructor(ctx: Context, config: Config) {
super(ctx)
// Schemastery validates and fills the defaults before constructing the service.
this.retainBytes = (config as Required<Config>).retainBytes
this.settledRetainBytes = (config as Required<Config>).settledRetainBytes
this.selfCtx = ctx
ctx.effect(() => () => { this.disposeAll() }, 'activities teardown')
}
open(spec: ActivityOpen): ActivityHandle {
if (spec.kind.length === 0) throw new Error('invalid activity kind: expected a non-empty string')
if (spec.label.length === 0) throw new Error('invalid activity label: expected a non-empty string')
if (spec.owner !== undefined) this.ensureOwnerCleanup(spec.owner)
const count = (this.counters.get(spec.kind) ?? 0) + 1
this.counters.set(spec.kind, count)
const id = ActivityId(`${spec.kind}-${count}`)
const record: TrackedActivity = {
id,
kind: spec.kind,
label: spec.label,
owner: spec.owner,
correlation: spec.correlation === undefined ? undefined : { ...spec.correlation },
status: 'running',
detail: undefined,
startedAt: Date.now(),
finishedAt: undefined,
chunks: [],
retainedBytes: 0,
total: 0,
earliest: 0,
}
this.store.set(id, record)
// Registration is complete and cannot fail from here, so the visible set
// has genuinely changed.
this.notifyChanged(record.owner)
return {
id,
append: (text, options) => { this.append(record, text, options) },
updateDetail: (detail) => { this.updateDetail(record, detail) },
end: (outcome) => { this.settle(record, outcome) },
}
}
list(caller?: Agent): ActivitySnapshot[] {
const session = caller?.id
return [...this.store.values()]
.filter(record => record.owner === undefined || record.owner.id === session)
.map(record => this.snapshot(record))
}
get(id: ActivityId, caller?: Agent): ActivitySnapshot {
const record = this.expect(id)
this.assertAccess(record, caller)
return this.snapshot(record)
}
read(id: ActivityId, from: number, caller?: Agent): ActivityRead {
const record = this.expect(id)
this.assertAccess(record, caller)
if (!Number.isSafeInteger(from) || from < 0) {
throw new Error(`invalid read offset: expected a non-negative safe integer, got ${JSON.stringify(from)}`)
}
const chunks: ActivityOutputChunk[] = []
for (const chunk of record.chunks) {
if (chunk.at + chunk.bytes <= from) continue
chunks.push({
at: chunk.at,
text: chunk.text,
...chunk.channel !== undefined ? { channel: chunk.channel } : {},
...chunk.gapBefore !== undefined ? { gapBefore: chunk.gapBefore } : {},
})
}
return { chunks, next: record.total, lossy: from < record.earliest }
}
onActivitiesChanged(listener: ActivitiesChangedListener): () => void {
return this.layers.effect(
this.ctx,
layer => layer.changed.append(listener),
{ label: 'activities.onActivitiesChanged()' },
)
}
onOutput(listener: ActivityOutputListener): () => void {
return this.layers.effect(
this.ctx,
layer => layer.output.append(listener),
{ label: 'activities.onOutput()' },
)
}
/** Append one chunk to a live record; log and drop on an ended one. */
private append(record: TrackedActivity, text: string, options?: ActivityAppendOptions): void {
if (isTerminal(record.status)) {
this.selfCtx.logger.warn(`activities: append to ended activity ${record.id} dropped`)
return
}
if (text.length === 0) return
const bytes = Buffer.byteLength(text, 'utf8')
record.chunks.push({
at: record.total,
text,
bytes,
...options?.channel !== undefined ? { channel: options.channel } : {},
...options?.gapBefore !== undefined ? { gapBefore: options.gapBefore } : {},
})
record.total += bytes
record.retainedBytes += bytes
this.trim(record, this.retainBytes)
this.notifyOutput(record)
}
/** Replace a live record's detail line; log and drop on an ended one. */
private updateDetail(record: TrackedActivity, detail: string): void {
if (isTerminal(record.status)) {
this.selfCtx.logger.warn(`activities: detail update on ended activity ${record.id} dropped`)
return
}
record.detail = detail
this.notifyChanged(record.owner)
}
/**
* Record the first terminal outcome, trim retention to the settled cap, and
* announce the change and the stream end. First-wins: a later call — the
* producer's own report after a teardown force-end, or a duplicate — is
* dropped silently.
*/
private settle(record: TrackedActivity, outcome: ActivityOutcome): void {
if (isTerminal(record.status)) return
record.status = outcome.status
if (outcome.detail !== undefined) record.detail = outcome.detail
record.finishedAt = Date.now()
this.trim(record, this.settledRetainBytes)
this.notifyChanged(record.owner)
this.notifyOutput(record)
}
/**
* Drop retained head chunks until the ring fits `cap`; a single oversized
* chunk keeps only its UTF-8-safe tail with a `gapBefore` marker. Offsets
* stay absolute: `earliest` advances over everything dropped.
*/
private trim(record: TrackedActivity, cap: number): void {
while (record.retainedBytes > cap && record.chunks.length > 1) {
const dropped = record.chunks.shift()
/* v8 ignore next -- the length guard proves shift() returns a chunk; the check only satisfies noUncheckedIndexedAccess. */
if (dropped === undefined) break
record.retainedBytes -= dropped.bytes
}
const single = record.chunks.length === 1 ? record.chunks[0] : undefined
if (single !== undefined && single.bytes > cap) {
const tail = utf8Tail(single.text, cap)
single.at += single.bytes - tail.bytes
single.text = tail.text
single.bytes = tail.bytes
single.gapBefore = true
record.retainedBytes = tail.bytes
}
record.earliest = record.chunks[0]?.at ?? record.total
}
/** Look up an activity or fail loud. */
private expect(id: ActivityId): TrackedActivity {
const record = this.store.get(id)
if (record === undefined) throw new Error(`unknown activity ${id}`)
return record
}
/**
* The visibility fence: an activity with an owner is reachable only by
* callers whose session id matches (`!== undefined` semantics — an unowned
* activity is open, and a no-agent caller can never match an owned one).
*/
private assertAccess(record: TrackedActivity, caller?: Agent): void {
if (record.owner !== undefined && record.owner.id !== caller?.id) {
throw new Error(`activity ${record.id} belongs to another session`)
}
}
/** Project a fresh read-only snapshot from the mutable record. */
private snapshot(record: TrackedActivity): ActivitySnapshot {
const ownerSession = record.owner?.id
return {
id: record.id,
kind: record.kind,
label: record.label,
...ownerSession !== undefined ? { ownerSession } : {},
...record.correlation !== undefined ? { correlation: { ...record.correlation } } : {},
status: record.status,
...record.detail !== undefined ? { detail: record.detail } : {},
startedAt: record.startedAt,
...record.finishedAt !== undefined ? { finishedAt: record.finishedAt } : {},
outputTotal: record.total,
outputEarliest: record.earliest,
}
}
/**
* The change observers that own `owner`'s updates: the global layer — a
* host composition's own carrier, which serves every owner — then each
* scoped layer along the owner's chain.
*/
private *changedFor(owner?: Agent): IterableIterator<ActivitiesChangedListener> {
yield* this.layers.global.changed.values()
const scope = owner === undefined ? undefined : scopeOf(owner.ctx)
for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values()
}
/** The output observers that own `owner`'s stream signals, resolved like {@link changedFor}. */
private *outputFor(owner?: Agent): IterableIterator<ActivityOutputListener> {
yield* this.layers.global.output.values()
const scope = owner === undefined ? undefined : scopeOf(owner.ctx)
for (const layer of this.layers.chainLayers(scope)) yield* layer.output.values()
}
/**
* Announce that one owner's visible set changed. Each listener is contained
* so an observer cannot break a lifecycle commit that already happened.
*/
private notifyChanged(owner: Agent | undefined): void {
for (const listener of this.changedFor(owner)) {
try {
listener(owner)
} catch (error: unknown) {
this.selfCtx.logger.warn(`activities: onActivitiesChanged listener threw: ${String(error)}`)
}
}
}
/** Announce that one activity's stream advanced (append or settlement), with per-listener containment. */
private notifyOutput(record: TrackedActivity): void {
for (const listener of this.outputFor(record.owner)) {
try {
listener(record.id)
} catch (error: unknown) {
this.selfCtx.logger.warn(`activities: onOutput listener threw: ${String(error)}`)
}
}
}
/**
* Attach one cleanup through the exact owner's scope. This survives
* producer reloads; the retained disposer lets service teardown detach the
* cross-fiber effect. Fails when the agent registry is absent or the owner
* is not its currently registered instance.
*/
private ensureOwnerCleanup(owner: Agent): void {
const ownerId = owner.id
const agents = this.selfCtx.get('agents')
if (agents === undefined) {
throw new Error('activity ownership requires the agent registry (load @deepseek-ai/dsh-agent)')
}
if (agents.get(ownerId) !== owner) {
throw new Error(`agent "${ownerId}" is not the registered agent instance (activity owner must be live)`)
}
if (this.ownerCleanups.has(owner)) return
// Record only after attach succeeds; a disposing scope rejects new effects.
const detach = owner.ctx.effect(() => () => {
this.ownerCleanups.delete(owner)
this.disposeOwned(owner)
}, 'activities.ownerCleanup()')
this.ownerCleanups.set(owner, detach)
}
/** End still-open records and drop every activity owned by one exact agent lifecycle. */
private disposeOwned(owner: Agent): void {
const owned = [...this.store.values()].filter(record => record.owner === owner)
for (const record of owned) {
if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'owner disposed' })
this.store.delete(record.id)
}
// Removal is the one visible-set change no per-activity record carries, so
// it must be announced here or an observer keeps the dropped rows forever.
if (owned.length > 0) this.notifyChanged(owner)
}
/** End every open record, clear the store, and announce each emptied owner. */
private disposeAll(): void {
const all = [...this.store.values()]
for (const record of all) {
if (!isTerminal(record.status)) this.settle(record, { status: 'killed', detail: 'activity registry disposed' })
}
const emptied = new Set(all.map(record => record.owner))
this.store.clear()
for (const owner of emptied) this.notifyChanged(owner)
// Detach cross-fiber owner effects after the shared store is quiescent.
const ownerCleanups = [...this.ownerCleanups.values()]
this.ownerCleanups.clear()
for (const cleanup of ownerCleanups) void cleanup()
}
}
export default LocalActivityRegistry
@@ -1,33 +0,0 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-activity-local`.
* @module @deepseek-ai/dsh-activity-local/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-activity-local'
/** Cordis companion plugin name. */
export const name = 'activity-local-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns per-snapshot identity, status,
* timestamp, and offset checks on every visible-set change. This provider's remaining behavior —
* ring retention against private byte caps and pull-only chunk reads — has no authoritative event
* stream to observe: a read happens on a caller's schedule, and repeating the retention arithmetic
* against private configuration would duplicate the implementation rather than check a relation.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -1,413 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import { ActivityId } from '@deepseek-ai/dsh-activity'
import type { ActivityHandle, ActivitySnapshot } from '@deepseek-ai/dsh-activity'
import LocalActivityRegistry, { type Config as ProcessConfig } from '@deepseek-ai/dsh-activity-local'
declare module '@deepseek-ai/dsh-activity' {
interface ActivityKindMap {
workflow: 'workflow'
}
}
const agentScopeDisposers = new WeakMap<Agent, () => Promise<void>>()
/**
* A minimal live agent whose ctx carries its own scope on a disposable plugin
* fiber, mirroring the harness convention that a live agent keys its scope.
*/
function stubAgent(ctx: Context, rawId: string): Agent {
const id = SessionId(rawId)
const scopeFiber = ctx.plugin(() => {})
const agentCtx = createScope(scopeFiber.ctx, {}).ctx
const session = Session.create(id)
const agent = {
id,
options: {},
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle' as const,
ctx: agentCtx,
send: () => {},
followup: () => {},
steer: () => ({ outcome: Promise.resolve({ status: 'rejected' as const }) }),
inject: () => {},
cancel() {},
runMaintenance: <T>(job: (signal: AbortSignal) => Promise<T>) => job(new AbortController().signal),
whenIdle() { return Promise.resolve() },
}
agentScopeDisposers.set(agent, async () => { await scopeFiber.dispose() })
return agent
}
async function disposeAgentScope(agent: Agent): Promise<void> {
const dispose = agentScopeDisposers.get(agent)
if (dispose === undefined) throw new Error(`missing test scope for agent "${agent.id}"`)
await dispose()
}
async function harness(config: ProcessConfig = {}) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalActivityRegistry, config)
return ctx
}
/** Concatenated retained text of one process, as an observer from `from` sees it. */
function textFrom(ctx: Context, id: ActivityId, from = 0): string {
return ctx.activities.read(id, from).chunks.map(chunk => chunk.text).join('')
}
describe('LocalActivityRegistry.open', () => {
it('mints per-kind ordinal ids and announces the new visible row', async () => {
const ctx = await harness()
const changes: (Agent | undefined)[] = []
ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) })
const first = ctx.activities.open({ kind: 'bash', label: 'sleep 60' })
const second = ctx.activities.open({ kind: 'workflow', label: 'run docs' })
const third = ctx.activities.open({ kind: 'bash', label: 'build' })
expect([first.id, second.id, third.id]).toEqual(['bash-1', 'workflow-1', 'bash-2'])
expect(changes).toEqual([undefined, undefined, undefined])
expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual(['bash-1', 'workflow-1', 'bash-2'])
})
it('rejects an empty kind or label without minting an id', async () => {
const ctx = await harness()
expect(() => ctx.activities.open({ kind: '' as never, label: 'x' })).toThrow(/invalid activity kind/)
expect(() => ctx.activities.open({ kind: 'bash', label: '' })).toThrow(/invalid activity label/)
expect(ctx.activities.list()).toEqual([])
})
it('rejects an owned open when no agent registry is loaded', async () => {
const bare = new Context()
await bare.plugin(LocalActivityRegistry)
const ghost = { id: SessionId('ghost'), ctx: bare } as unknown as Agent
expect(() => bare.activities.open({ kind: 'bash', label: 'x', owner: ghost }))
.toThrow(/activity ownership requires the agent registry/)
})
it('rejects an owner that is not the registered agent instance', async () => {
const ctx = await harness()
const ghost = stubAgent(ctx, 'ghost')
expect(() => ctx.activities.open({ kind: 'bash', label: 'x', owner: ghost }))
.toThrow(/not the registered agent instance/)
})
it('carries owner session and correlation into snapshots', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const handle = ctx.activities.open({
kind: 'bash',
label: 'npm test',
owner,
correlation: { jobId: 'bash-9' as never, callId: 'call-3' as never },
})
const snapshot = ctx.activities.get(handle.id, owner)
expect(snapshot.ownerSession).toBe(owner.id)
expect(snapshot.correlation).toEqual({ jobId: 'bash-9', callId: 'call-3' })
expect(snapshot.status).toBe('running')
})
})
describe('LocalActivityRegistry output', () => {
it('appends advance absolute offsets and reads are non-consuming', async () => {
const ctx = await harness()
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
handle.append('hello ')
handle.append('world', { channel: 'stderr' })
const first = ctx.activities.read(handle.id, 0)
expect(first.lossy).toBe(false)
expect(first.next).toBe(11)
expect(first.chunks).toEqual([
{ at: 0, text: 'hello ' },
{ at: 6, text: 'world', channel: 'stderr' },
])
// A second identical read proves nothing was consumed.
expect(ctx.activities.read(handle.id, 0)).toEqual(first)
// Resuming from `next` yields nothing until more output arrives.
expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([])
handle.append('!')
expect(ctx.activities.read(handle.id, first.next).chunks).toEqual([{ at: 11, text: '!' }])
const snapshot = ctx.activities.get(handle.id)
expect(snapshot.outputTotal).toBe(12)
expect(snapshot.outputEarliest).toBe(0)
})
it('drops an empty append without waking observers', async () => {
const ctx = await harness()
const signals: ActivityId[] = []
ctx.activities.onOutput((id) => { signals.push(id) })
const handle = ctx.activities.open({ kind: 'bash', label: 'quiet' })
handle.append('')
expect(signals).toEqual([])
expect(ctx.activities.get(handle.id).outputTotal).toBe(0)
})
it('rejects a negative or fractional read offset', async () => {
const ctx = await harness()
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
expect(() => ctx.activities.read(handle.id, -1)).toThrow(/invalid read offset/)
expect(() => ctx.activities.read(handle.id, 0.5)).toThrow(/invalid read offset/)
})
it('evicts whole head chunks past the live cap and flags stale readers lossy', async () => {
const ctx = await harness({ retainBytes: 8 })
const handle = ctx.activities.open({ kind: 'bash', label: 'spam' })
handle.append('aaaa')
handle.append('bbbb')
handle.append('cccc')
const read = ctx.activities.read(handle.id, 0)
expect(read.lossy).toBe(true)
expect(read.chunks).toEqual([{ at: 4, text: 'bbbb' }, { at: 8, text: 'cccc' }])
expect(read.next).toBe(12)
const snapshot = ctx.activities.get(handle.id)
expect(snapshot.outputEarliest).toBe(4)
expect(snapshot.outputTotal).toBe(12)
// A reader at the retained boundary is not lossy.
expect(ctx.activities.read(handle.id, 4).lossy).toBe(false)
})
it('keeps only the UTF-8-safe tail of a single oversized chunk', async () => {
const ctx = await harness({ retainBytes: 5 })
const handle = ctx.activities.open({ kind: 'bash', label: 'wide' })
// '你好' is six UTF-8 bytes; a five-byte budget must not split the second code point.
handle.append('你好')
const read = ctx.activities.read(handle.id, 0)
expect(read.lossy).toBe(true)
expect(read.chunks).toEqual([{ at: 3, text: '好', gapBefore: true }])
const snapshot = ctx.activities.get(handle.id)
expect(snapshot.outputEarliest).toBe(3)
expect(snapshot.outputTotal).toBe(6)
})
it('returns the whole overlapped chunk for a foreign mid-chunk offset', async () => {
const ctx = await harness()
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
handle.append('abcdef')
const read = ctx.activities.read(handle.id, 3)
expect(read.chunks).toEqual([{ at: 0, text: 'abcdef' }])
expect(read.lossy).toBe(false)
})
it('preserves a producer-reported gap marker through reads', async () => {
const ctx = await harness()
const handle = ctx.activities.open({ kind: 'bash', label: 'lossy source' })
handle.append('tail after drop', { gapBefore: true, channel: 'stdout' })
expect(ctx.activities.read(handle.id, 0).chunks).toEqual([
{ at: 0, text: 'tail after drop', channel: 'stdout', gapBefore: true },
])
})
})
describe('LocalActivityRegistry settlement', () => {
it('records the first terminal outcome and trims retention to the settled cap', async () => {
const ctx = await harness({ retainBytes: 1024, settledRetainBytes: 4 })
const handle = ctx.activities.open({ kind: 'bash', label: 'build' })
handle.append('abcdefgh')
handle.end({ status: 'completed', detail: 'exit code: 0' })
const snapshot = ctx.activities.get(handle.id)
expect(snapshot.status).toBe('completed')
expect(snapshot.detail).toBe('exit code: 0')
expect(snapshot.finishedAt).toBeDefined()
const read = ctx.activities.read(handle.id, 0)
expect(read.lossy).toBe(true)
expect(read.chunks).toEqual([{ at: 4, text: 'efgh', gapBefore: true }])
})
it('is first-wins: a later end and post-end writes are dropped without throwing', async () => {
const ctx = await harness()
const warn = vi.fn()
ctx.logger.warn = warn as never
const handle = ctx.activities.open({ kind: 'bash', label: 'build' })
handle.end({ status: 'killed', detail: 'owner disposed' })
handle.end({ status: 'completed' })
handle.append('late')
handle.updateDetail('late detail')
const snapshot = ctx.activities.get(handle.id)
expect(snapshot.status).toBe('killed')
expect(snapshot.detail).toBe('owner disposed')
expect(snapshot.outputTotal).toBe(0)
expect(warn.mock.calls.map(call => String(call[0]))).toEqual([
expect.stringContaining('append to ended activity'),
expect.stringContaining('detail update on ended activity'),
])
})
it('signals observers for appends and once more at settlement', async () => {
const ctx = await harness()
const signals: ActivityId[] = []
ctx.activities.onOutput((id) => { signals.push(id) })
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
handle.append('a')
handle.append('b')
handle.end({ status: 'completed' })
expect(signals).toEqual([handle.id, handle.id, handle.id])
})
it('updateDetail changes the visible row without touching output offsets', async () => {
const ctx = await harness()
const changes: (Agent | undefined)[] = []
ctx.activities.onActivitiesChanged((owner) => { changes.push(owner) })
const handle = ctx.activities.open({ kind: 'workflow', label: 'docs run' })
handle.updateDetail('3/10 agents done')
expect(ctx.activities.get(handle.id).detail).toBe('3/10 agents done')
expect(ctx.activities.get(handle.id).outputTotal).toBe(0)
expect(changes).toHaveLength(2)
})
})
describe('LocalActivityRegistry visibility and delivery', () => {
it('fences owned processes by session id and keeps unowned ones open', async () => {
const ctx = await harness()
const alice = stubAgent(ctx, 'alice')
const bob = stubAgent(ctx, 'bob')
ctx.agents.register(alice)
ctx.agents.register(bob)
const owned = ctx.activities.open({ kind: 'bash', label: 'alice job', owner: alice })
const open = ctx.activities.open({ kind: 'bash', label: 'shared' })
owned.append('secret')
expect(ctx.activities.list(alice).map(snapshot => snapshot.id)).toEqual([owned.id, open.id])
expect(ctx.activities.list(bob).map(snapshot => snapshot.id)).toEqual([open.id])
expect(ctx.activities.list().map(snapshot => snapshot.id)).toEqual([open.id])
expect(() => ctx.activities.get(owned.id, bob)).toThrow(/belongs to another session/)
expect(() => ctx.activities.read(owned.id, 0)).toThrow(/belongs to another session/)
expect(textFrom(ctx, open.id)).toBe('')
expect(ctx.activities.read(owned.id, 0, alice).chunks[0]?.text).toBe('secret')
})
it('delivers scoped listeners only their own agents and global listeners everything', async () => {
const ctx = await harness()
const alice = stubAgent(ctx, 'alice')
const bob = stubAgent(ctx, 'bob')
ctx.agents.register(alice)
ctx.agents.register(bob)
const globalOwners: (string | undefined)[] = []
ctx.activities.onActivitiesChanged((owner) => { globalOwners.push(owner?.id) })
const scopedOwners: (string | undefined)[] = []
await alice.ctx.plugin({
inject: ['activities'],
apply(scopedCtx: Context) {
scopedCtx.activities.onActivitiesChanged((owner) => { scopedOwners.push(owner?.id) })
},
})
const scopedOutputs: string[] = []
const scopedFiber = await alice.ctx.plugin({
inject: ['activities'],
apply(scopedCtx: Context) {
scopedCtx.activities.onOutput((id) => { scopedOutputs.push(String(id)) })
},
})
const aliceHandle = ctx.activities.open({ kind: 'bash', label: 'alice work', owner: alice })
aliceHandle.append('scoped delivery probe')
expect(scopedOutputs).toEqual([String(aliceHandle.id)])
ctx.activities.open({ kind: 'bash', label: 'bob work', owner: bob })
ctx.activities.open({ kind: 'bash', label: 'unowned' })
expect(globalOwners).toEqual(['alice', 'bob', undefined])
expect(scopedOwners).toEqual(['alice'])
// Disposing one scoped registrant probes layer reclamation; the sibling
// scoped changed-listener keeps the layer alive and keeps delivering.
await scopedFiber.dispose()
ctx.activities.open({ kind: 'bash', label: 'alice later', owner: alice })
expect(scopedOwners).toEqual(['alice', 'alice'])
// A scope whose only contribution goes empties on disposal and is reclaimed.
const bobFiber = await bob.ctx.plugin({
inject: ['activities'],
apply(scopedCtx: Context) {
scopedCtx.activities.onOutput(() => {})
},
})
await bobFiber.dispose()
})
it('contains throwing listeners without breaking the commit', async () => {
const ctx = await harness()
const warn = vi.fn()
ctx.logger.warn = warn as never
ctx.activities.onActivitiesChanged(() => { throw new Error('changed boom') })
ctx.activities.onOutput(() => { throw new Error('output boom') })
const seen: (Agent | undefined)[] = []
ctx.activities.onActivitiesChanged((owner) => { seen.push(owner) })
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
handle.append('x')
expect(seen).toHaveLength(1)
expect(ctx.activities.get(handle.id).outputTotal).toBe(1)
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onActivitiesChanged listener threw'))
expect(warn).toHaveBeenCalledWith(expect.stringContaining('onOutput listener threw'))
})
it('unregisters listeners with their registering fiber', async () => {
const ctx = await harness()
const signals: ActivityId[] = []
const fiber = ctx.plugin({
inject: ['activities'],
apply(pluginCtx: Context) {
pluginCtx.activities.onOutput((id) => { signals.push(id) })
},
})
await fiber
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
handle.append('a')
expect(signals).toHaveLength(1)
await fiber.dispose()
handle.append('b')
expect(signals).toHaveLength(1)
})
})
describe('LocalActivityRegistry lifecycle', () => {
it('owner disposal ends open records, removes rows, and announces the removal', async () => {
const ctx = await harness()
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const handle = ctx.activities.open({ kind: 'bash', label: 'sleep', owner })
handle.append('partial')
const settledEarly = ctx.activities.open({ kind: 'bash', label: 'done already', owner })
settledEarly.end({ status: 'completed' })
const changes: (string | undefined)[] = []
ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) })
await disposeAgentScope(owner)
expect(ctx.activities.list(owner)).toEqual([])
expect(() => ctx.activities.get(handle.id, owner)).toThrow(/unknown activity/)
// The forced end and the removal both announced under the owner.
expect(changes.length).toBeGreaterThanOrEqual(2)
expect(new Set(changes)).toEqual(new Set(['owner']))
// The producer's own late report is dropped silently, not thrown.
expect(() => { handle.end({ status: 'completed' }) }).not.toThrow()
})
it('service disposal ends everything, clears the store, and announces emptied owners', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
const registryFiber = ctx.plugin(LocalActivityRegistry, {})
await registryFiber
const owner = stubAgent(ctx, 'owner')
ctx.agents.register(owner)
const processes = ctx.activities
const owned = processes.open({ kind: 'bash', label: 'owned', owner })
processes.open({ kind: 'bash', label: 'unowned' })
const preSettled = processes.open({ kind: 'bash', label: 'already settled' })
preSettled.end({ status: 'completed' })
const changes: (string | undefined)[] = []
ctx.activities.onActivitiesChanged((changed) => { changes.push(changed?.id) })
await registryFiber.dispose()
expect(changes).toEqual(expect.arrayContaining(['owner', undefined]))
expect(() => { owned.append('late') }).not.toThrow()
})
})
describe('LocalActivityRegistry snapshots', () => {
it('hands out fresh snapshots, never live registry state', async () => {
const ctx = await harness()
const handle: ActivityHandle = ctx.activities.open({ kind: 'bash', label: 'echo' })
const before: ActivitySnapshot = ctx.activities.get(handle.id)
handle.append('grow')
expect(before.outputTotal).toBe(0)
expect(ctx.activities.get(handle.id).outputTotal).toBe(4)
})
})
@@ -1,60 +0,0 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import { Context } from '@deepseek-ai/cordis'
import Include from '@deepseek-ai/cordis-plugin-include'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
let root: string | undefined
let context: Context | undefined
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
describe('process-local through a real Loader composition', () => {
it('applies the provider-owned retention config from a Cordis row', async () => {
root = await mkdtemp(join(tmpdir(), 'dsh-activity-local-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-activity-local'",
' config:',
' retainBytes: 8',
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (specifier === '@deepseek-ai/dsh-activity-local') return LocalActivityRegistry
throw new Error(`unexpected Loader import: ${specifier}`)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
expect(context.activities).toBeInstanceOf(LocalActivityRegistry)
const handle = context.activities.open({ kind: 'bash', label: 'loader retention probe' })
handle.append('aaaa')
handle.append('bbbb')
handle.append('cccc')
const read = context.activities.read(handle.id, 0)
// retainBytes: 8 from the Cordis row dropped the oldest chunk.
expect(read.lossy).toBe(true)
expect(read.chunks.map(chunk => chunk.text)).toEqual(['bbbb', 'cccc'])
expect(read.next).toBe(12)
})
})
@@ -1,33 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/scope"
},
{
"path": "../activity"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/activity/activity/README.md
README.md: 466bdf5aac8718b197495f477f012e6314e41dd6
README.zh.md: 32dea93f72f12ae1f439220193b8fa5b994bbe45
-101
View File
@@ -1,101 +0,0 @@
---
description: "The streaming-output observation contract (ctx.activities): open/append/end handles, non-consuming offset reads, owner fencing, and the shared pull pump for producers and consumers of live activity output."
kind: "package-reference"
---
# @deepseek-ai/dsh-activity
English | [中文](README.zh.md)
## Summary
`dsh-activity` lets a long-running producer publish live output and status once, and any number of independent consumers read it without stealing bytes from the model: reads are non-consuming, addressed by absolute UTF-8 byte offsets that survive eviction, and fenced to the owning session. The registry is a pure observation plane — it starts nothing, cancels nothing, and is invisible to the model, so producers keep their execution resources and their existing model-facing surfaces. This package ships the contract only; load [`dsh-activity-local`](../activity-local/README.md) to get a working registry, and without one `ctx.activities` does not exist and observation silently degrades to none.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Use this package when you write a producer that mirrors live work, or a consumer that renders it; the composition mounts an implementation row (`@deepseek-ai/dsh-activity-local`), never this contract package directly.
### Producing an activity
`open(spec): ActivityHandle` validates the kind, label, and exact live owner, then atomically registers the record and returns the producer face: synchronous `append(text, { channel?, gapBefore? })`, `updateDetail(detail)`, and first-wins `end(outcome)`. After `end` — including a teardown force-end — `append` and `updateDetail` log and drop instead of throwing, so a producer's trailing flush cannot break its own teardown. Producer plugins extend `ActivityKindMap` with their opaque id namespace and may link an activity to its tool call and background job through `ActivityCorrelation`. Access the registry with `ctx.get('activities')` and contain every observation failure: the plane is optional, and the observed work must never depend on it.
### Consuming activities
`get(id, caller?)` and `list(caller?)` return fresh snapshots; listing includes only caller-owned and unowned activities, and predictable ids make that fence the boundary. `read(id, from, caller?)` returns the retained chunks overlapping `[from, total)` plus the resume offset, without consuming anything; a reader below the oldest retained byte gets `lossy: true`, never an error. `onActivitiesChanged(listener)` observes visible-set changes so consumers re-read rather than accumulate deltas, and `onOutput(listener)` signals stream advancement carrying only the id; both deliver owner-relative and contain every listener.
### Pumping a substrate
`pumpActivityOutput(handle, sources, { pollMs, done })` is the shared pump for producers whose substrate is a non-consuming offset reader (the subprocess `readFrom(fromByte)` family): it copies labeled deltas at a bounded cadence, marks a lossy source read `gapBefore`, and drains once more after `done` settles without ever calling `end()` itself.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
The package holds the abstract `ActivityRegistry` service (a direct mount throws, naming an implementation to load), the vocabulary types, the branded `ActivityId`, the pump, and the invariant companion. Offsets are absolute so an observer's cursor never depends on what other observers did, and eviction moves only the earliest retained byte — the lossy flag is how a late reader learns the head is gone.
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Abstract `ActivityRegistry` service and the direct-mount guard |
| [`src/types.ts`](src/types.ts) | Vocabulary: kinds, snapshots, handles, reads, listeners |
| [`src/brand.ts`](src/brand.ts) | Branded `ActivityId` (browser-safe leaf) |
| [`src/pump.ts`](src/pump.ts) | `pumpActivityOutput` pull-substrate pump |
| [`src/invariant.ts`](src/invariant.ts) | Snapshot invariants installed on the change feed |
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [`dsh-activity-local`](../activity-local/README.md) — the in-memory implementation and its retention config.
- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — the subsystem reference: offsets, retention, listener delivery.
- [`dsh-api-activity-controller`](../../api/activity-controller/README.md) — the Remote streams serving this plane to browsers.
- [Observation-seam Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.md) — why the plane is non-consuming and model-invisible.
-----
<a id="model-experience"></a>
## Model Experience
None, as the registry carries live observation state for humans and registers no prompt, tool, or session event; activity output becomes model-visible only through the producers' existing tool results and `ctx.jobs` reads.
#### KV Cache effect
None; the package never assembles or alters provider requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
These limits define when the contract is a poor fit. They are current package constraints, not a task backlog.
- **Records are process-local and live-only** — a Host restart empties every roster while the transcript keeps the producing tool cards; durable replay is a separate design.
- **The observation plane has no cancellation verb** — stopping work stays with the producers and `ctx.jobs`; a panel-initiated kill is blocked on the jobs `reported` contract question recorded in the [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.md).
- **Settled records last until owner disposal** — retention trims their buffers to the settled cap, but the rows themselves are not aged out.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
-101
View File
@@ -1,101 +0,0 @@
---
description: "流式输出观察契约(ctx.activities):open/append/end 句柄、非消费偏移读取、owner 栅栏,以及供实时活动输出生产者与消费者共用的拉取泵。"
kind: "package-reference"
---
# @deepseek-ai/dsh-activity
[English](README.md) | 中文
## 概述
`dsh-activity` 让长时运行的生产者只发布一次实时输出与状态,任意数量的独立消费者读取它而不会偷走模型的字节:读取是非消费的,以跨淘汰仍有效的绝对 UTF-8 字节偏移寻址,并以归属会话为栅栏。注册表是纯观察面——它什么都不启动、什么都不取消、对模型不可见,因此生产者保留自己的执行资源与既有模型可见表面。本包只交付契约;加载 [`dsh-activity-local`](../activity-local/README.zh.md) 获得可用注册表,没有实现时 `ctx.activities` 不存在,观察静默退化为无。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
编写镜像实时工作的生产者、或渲染它的消费者时使用本包;组合挂载的是实现行(`@deepseek-ai/dsh-activity-local`),从不直接挂载这个契约包。
### 生产一个 activity
`open(spec): ActivityHandle` 校验 kind、label 与确切的活跃 owner,然后原子注册记录并返回生产者面:同步的 `append(text, { channel?, gapBefore? })`、`updateDetail(detail)`,以及先到先赢的 `end(outcome)`。在 `end` 之后——包括 teardown 强制收尾——`append` 与 `updateDetail` 记日志后丢弃而非抛出,因此生产者的尾部 flush 不会破坏自己的 teardown。生产者插件以自己的不透明 id 命名空间扩展 `ActivityKindMap`,并可通过 `ActivityCorrelation` 把 activity 关联到其工具调用与后台 job。用 `ctx.get('activities')` 访问注册表并包住每个观察失败:该面是可选的,被观察的工作绝不能依赖它。
### 消费 activities
`get(id, caller?)` 与 `list(caller?)` 返回新鲜快照;列表只含调用方拥有与无主的 activity,可预测的 id 使该栅栏成为边界。`read(id, from, caller?)` 返回与 `[from, total)` 重叠的留存 chunk 加恢复偏移,不消费任何东西;读到最旧留存字节之前的位置得到 `lossy: true`,绝不报错。`onActivitiesChanged(listener)` 观察可见集变化,消费者据此重读而非累积增量;`onOutput(listener)` 以仅含 id 的信号提示流推进;两者按 owner 相对投递并包住每个监听器。
### 泵送一个底座
`pumpActivityOutput(handle, sources, { pollMs, done })` 是底座为非消费偏移读取器(subprocess 的 `readFrom(fromByte)` 家族)的生产者的共用泵:它以有界节奏复制带标签的增量,把有损的源读取标为 `gapBefore`,并在 `done` 结算后再排空一次,自己从不调用 `end()`。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内幕——点击展开</summary>
本包持有抽象 `ActivityRegistry` 服务(直接挂载会抛出并指名应加载的实现)、词汇类型、branded `ActivityId`、泵与 invariant 伴生。偏移是绝对的,观察者的游标从不依赖其他观察者做了什么;淘汰只前移最旧留存字节——lossy 标志就是迟到读者得知头部已丢的方式。
| 文件 | 角色 |
|---|---|
| [`src/index.ts`](src/index.ts) | 抽象 `ActivityRegistry` 服务与直接挂载防护 |
| [`src/types.ts`](src/types.ts) | 词汇:kind、快照、句柄、读取、监听器 |
| [`src/brand.ts`](src/brand.ts) | branded `ActivityId`(浏览器安全叶子) |
| [`src/pump.ts`](src/pump.ts) | `pumpActivityOutput` 拉取底座泵 |
| [`src/invariant.ts`](src/invariant.ts) | 装在变化流上的快照不变式 |
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
- [`dsh-activity-local`](../activity-local/README.zh.md)——内存实现及其留存配置。
- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——子系统参考:偏移、留存、监听器投递。
- [`dsh-api-activity-controller`](../../api/activity-controller/README.zh.md)——把该面提供给浏览器的 Remote 流。
- [观察面 Agent Note](../../../.agents/notes/implemented/feature/2026-08-24-activity-observation-seam.zh.md)——该面为何是非消费且模型不可见的。
-----
<a id="model-experience"></a>
## 模型体验
无:注册表为人承载实时观察状态,不注册任何 prompt、工具或会话事件;activity 输出只经由生产者既有的工具结果与 `ctx.jobs` 读取才对模型可见。
#### KV Cache 影响
无;本包从不组装或改动 provider 请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
这些限制说明契约何时不合适。它们是当前包约束,不是任务积压。
- **记录是进程本地且仅存活期**——Host 重启清空所有 roster,而 transcript 保留生产方的工具卡片;持久回放是另一项设计。
- **观察面没有取消动词**——停止工作仍属于生产者与 `ctx.jobs`;面板发起的 kill 被 [web job display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-job-display.zh.md) 记录的 jobs `reported` 契约问题阻塞。
- **已结算记录存续到 owner 释放**——留存把它们的缓冲裁到 settled 上限,但行本身不会被老化清除。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
</details>
-57
View File
@@ -1,57 +0,0 @@
{
"name": "@deepseek-ai/dsh-activity",
"description": "Streaming-output observation registry (ctx.activities) for the DeepSeek Harness — non-consuming offset reads, bounded retention, and change signals over any long-running producer's live output",
"version": "0.1.2-alpha.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/activity/activity"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./brand": {
"types": "./lib/types/brand.d.ts",
"default": "./lib/types/brand.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}
-28
View File
@@ -1,28 +0,0 @@
/**
* dsh-activity' owned branded id, carried across the registry, the observation
* wire, and correlation metadata on other domains' rows.
*
* It lives in its own leaf because the package root reaches `dsh-agent`
* through the owner and listener signatures, which a Client program cannot
* resolve even as a type. A browser-safe consumer imports the id here;
* `Branded<B>` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`.
*
* @module @deepseek-ai/dsh-activity/brand
*/
import type { Branded } from '@deepseek-ai/dsh-brand'
/**
* Identifies one observable activity. The registry generates `<kind>-N`;
* predictable ids rely on owner authorization rather than secrecy.
*/
export type ActivityId = Branded<'ActivityId'>
/**
* Brand a string as a {@link ActivityId}.
* @param id - the raw process-id string (the registry generates `<kind>-N`).
* @returns the same string, branded; no validation is performed.
*/
export function ActivityId(id: string): ActivityId {
return id as ActivityId
}
-138
View File
@@ -1,138 +0,0 @@
/**
* The streaming-output observation Service Definition (`ctx.activities`). An
* activity is one observable unit of long-running work: an append-only bounded
* output stream plus live status, readable by any number of independent
* observers at absolute byte offsets. The registry is a pure observation
* plane — it starts nothing, cancels nothing, and is invisible to the model;
* producers keep their execution resources and their existing model-facing
* surfaces (`ctx.jobs` cursors, tool results). The process-local registry
* lives in `@deepseek-ai/dsh-activity-local`.
* @module @deepseek-ai/dsh-activity
*/
import { Context, Service } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type {
ActivitiesChangedListener, ActivityHandle, ActivityId, ActivityOpen, ActivityOutputListener,
ActivityRead, ActivitySnapshot,
} from './types.ts'
export { ActivityId } from './brand.ts'
export type {
ActivityAppendOptions,
ActivityChannel,
ActivityCorrelation,
ActivitiesChangedListener,
ActivityHandle,
ActivityKind,
ActivityKindMap,
ActivityOpen,
ActivityOutcome,
ActivityOutputChunk,
ActivityOutputListener,
ActivityRead,
ActivitySnapshot,
ActivityStatus,
} from './types.ts'
export { pumpActivityOutput } from './pump.ts'
export type { ActivityPumpOptions, ActivityPumpRead, ActivityPumpSource } from './pump.ts'
declare module '@deepseek-ai/cordis' {
interface Context {
activities: ActivityRegistry
}
}
/**
* Abstract streaming-output registry. Subclass, implement the abstract
* methods, and load the subclass as a plugin — it registers as
* `ctx.activities` (one implementation per context; loading a second throws,
* which is cordis' standard duplicate-service behavior).
*
* Implementations must honor these semantics:
* - Observation never consumes. Any number of readers hold their own absolute
* byte offsets; a read changes no cursor and no producer state, so the
* model-facing paths (`ctx.jobs.read()`, tool results) are unaffected.
* - Records outlive producer fibers. Owner disposal ends still-open records
* and removes them; service disposal ends and clears everything. Neither
* awaits a producer — the registry owns no execution resource.
* - Retention is bounded. Appends past the live cap drop the oldest retained
* bytes; a reader below the retained window gets a lossy read, never an
* error. Settlement trims retention to the settled cap.
* - Listener delivery is owner-relative: a listener registered from an
* unscoped context — a host composition's own carrier — sees every owner,
* while one registered under an agent composition's scope sees exactly the
* agents composed under it. Every listener is contained.
*/
export abstract class ActivityRegistry extends Service {
constructor(ctx: Context) {
// `abstract` erases at runtime, so a composition row naming this package
// would register a ctx.activities with no method implementations and fail
// far from the misconfiguration. Fail loud at load instead.
if (new.target === ActivityRegistry) {
throw new Error('@deepseek-ai/dsh-activity is the abstract activity observation seam; load an implementation such as @deepseek-ai/dsh-activity-local instead')
}
super(ctx, 'activities')
}
/**
* Validate the spec, attach owner cleanup, and atomically register one
* activity. A rejection leaves no id; after return the producer owns the
* handle and the visible set has changed.
* @param spec - activity identity, owner, and correlation.
* @returns the producer face of the registered activity.
*/
abstract open(spec: ActivityOpen): ActivityHandle
/**
* List caller-owned and unowned activities in registration order without
* exposing another session's labels or output.
* @param caller - reading agent; a non-agent caller sees only unowned activities.
* @returns fresh snapshots.
*/
abstract list(caller?: Agent): ActivitySnapshot[]
/**
* Return a fresh snapshot. Throws for an unknown or foreign activity.
* @param id - activity to look up.
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
abstract get(id: ActivityId, caller?: Agent): ActivitySnapshot
/**
* Read retained output from an absolute byte offset without consuming it.
* Resume by passing a previous read's `next`; a foreign offset inside a
* retained chunk returns that whole chunk (its `at` may precede `from`).
* Throws for an unknown or foreign activity or a negative or non-integer
* offset.
* @param id - activity to read.
* @param from - absolute byte offset to read from (0 for the retained head).
* @param caller - reading agent checked against the owner.
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
*/
abstract read(id: ActivityId, from: number, caller?: Agent): ActivityRead
/**
* Register an effect-scoped observer of visible-set changes: opening,
* detail updates, settlement, owner-disposal removal, and the emptying that
* service disposal commits — so an observer re-reads rather than
* accumulating deltas. Listeners are contained and never awaited.
* @param listener - receives the owner whose visible set changed, or
* `undefined` when an unowned activity changed and every caller's set did.
* @returns disposer that unregisters the listener.
*/
abstract onActivitiesChanged(listener: ActivitiesChangedListener): () => void
/**
* Register an effect-scoped observer of stream advancement — one signal per
* committed append and one at settlement, carrying only the activity id.
* A consumer schedules a {@link read} from its own cursor; the registry
* never pushes payloads. Listeners are contained and never awaited.
* @param listener - receives the id whose stream advanced.
* @returns disposer that unregisters the listener.
*/
abstract onOutput(listener: ActivityOutputListener): () => void
}
export default ActivityRegistry
@@ -1,62 +0,0 @@
/** Package-owned process-snapshot invariants. @module @deepseek-ai/dsh-activity/invariant */
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ActivitySnapshot } from './types.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-activity'
const TERMINAL_STATUSES = new Set(['completed', 'failed', 'killed'])
/** Cordis companion plugin name. */
export const name = 'activity-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/** Validate the cross-field relationships in one registry snapshot. */
/* jscpd:ignore-start -- mirrors the jobs invariant's id/ordinal checks by design; the two registries share the `<kind>-N` scheme. */
function validateSnapshot(snapshot: ActivitySnapshot, fail: InvariantFailure): void {
const id = String(snapshot.id)
const prefix = `${snapshot.kind}-`
const ordinal = Number(id.slice(prefix.length))
if (snapshot.kind.length === 0 || !id.startsWith(prefix)
|| !Number.isSafeInteger(ordinal) || ordinal < 1) {
fail(`activity snapshot id ${JSON.stringify(id)} must be ${JSON.stringify(prefix)} followed by a positive ordinal`)
}
if (snapshot.label.length === 0) fail(`activity ${JSON.stringify(id)} label must be non-empty`)
if (!Number.isSafeInteger(snapshot.startedAt) || snapshot.startedAt < 0) {
fail(`activity ${JSON.stringify(id)} startedAt must be a non-negative epoch integer`)
}
/* jscpd:ignore-end */
const terminal = TERMINAL_STATUSES.has(snapshot.status)
if (terminal !== (snapshot.finishedAt !== undefined)) {
fail(`activity ${JSON.stringify(id)} finishedAt must be present exactly for a terminal status`)
}
if (snapshot.finishedAt !== undefined
&& (!Number.isSafeInteger(snapshot.finishedAt) || snapshot.finishedAt < snapshot.startedAt)) {
fail(`activity ${JSON.stringify(id)} finishedAt must be an epoch integer no earlier than startedAt`)
}
if (!Number.isSafeInteger(snapshot.outputTotal) || snapshot.outputTotal < 0
|| !Number.isSafeInteger(snapshot.outputEarliest) || snapshot.outputEarliest < 0
|| snapshot.outputEarliest > snapshot.outputTotal) {
fail(`activity ${JSON.stringify(id)} output offsets must satisfy 0 <= outputEarliest <= outputTotal`)
}
}
/** Install checks over current visible records and every later visible-set change. */
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
for (const snapshot of ctx.activities.list()) validateSnapshot(snapshot, fail)
ctx.activities.onActivitiesChanged((owner: Agent | undefined) => {
for (const snapshot of ctx.activities.list(owner)) validateSnapshot(snapshot, fail)
})
}, { inject: ['activities'] })
/**
* Register the process-registry invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
-184
View File
@@ -1,184 +0,0 @@
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { SessionId } from '@deepseek-ai/dsh-session'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { ActivityId } from './brand.ts'
export type { ActivityId } from './brand.ts'
/**
* Producer-defined activity kinds. Plugins extend this map by declaration
* merging; the registry treats every value as an opaque id namespace.
*/
export interface ActivityKindMap {
bash: 'bash'
}
/** A registered activity kind — also the id prefix (`bash`, `workflow`, …). */
export type ActivityKind = ActivityKindMap[keyof ActivityKindMap]
/** Lifecycle state of one observable activity. */
export type ActivityStatus = 'running' | 'completed' | 'failed' | 'killed'
/**
* Output stream label. Producers without distinct streams omit it; consumers
* treat an unrecognized label like an absent one.
*/
export type ActivityChannel = 'stdout' | 'stderr'
/**
* Links one activity to the records other domains keep for the same work, so a
* consumer can attach the output stream to an existing card or row without
* guessing from labels.
*/
export interface ActivityCorrelation {
/** Tool call that started the work, when a tool produced it. */
callId?: ToolCallId
/** Background job carrying the model-facing control surface, when one exists. */
jobId?: JobId
}
/**
* Producer declaration passed to {@link ActivityRegistry.open}. The registry
* owns identity, retention, and observer delivery; the producer keeps its
* execution resources and reports through the returned {@link ActivityHandle}.
*/
export interface ActivityOpen {
/** Producer kind — also the id prefix. */
kind: ActivityKind
/** One-line human-facing label (the command; the run description). */
label: string
/**
* Owning live agent. Visibility is fenced by its session id, and agent
* disposal ends and removes the record. The instance must be the one
* currently registered under its agent id. Omitting the owner creates an
* unowned activity, visible to any caller until service disposal.
*/
owner?: Agent
/** Optional links to the tool call and background job for the same work. */
correlation?: ActivityCorrelation
}
/** Terminal outcome reported through {@link ActivityHandle.end}. */
export interface ActivityOutcome {
/** How the work ended: finished (`completed`), broke (`failed`), or was cancelled (`killed`). */
status: 'completed' | 'failed' | 'killed'
/** Kind-specific detail rendered into status lines ('exit code: 3'). */
detail?: string
}
/** Options for one {@link ActivityHandle.append}. */
export interface ActivityAppendOptions {
/** Stream label for the chunk; omitted for producers without distinct streams. */
channel?: ActivityChannel
/**
* Marks that producer-side bytes between the previous chunk and this one
* were lost (a source window slid before the producer could copy it), so a
* consumer can render the discontinuity instead of a silent splice.
*/
gapBefore?: true
}
/**
* Producer face of one open activity. All methods are synchronous. After
* {@link end} — including a registry-forced end during owner or service
* teardown — `append` and `updateDetail` log and drop instead of throwing, so
* a producer's trailing flush cannot break its own teardown path.
*/
export interface ActivityHandle {
/** The registry-issued id (`<kind>-N`). */
readonly id: ActivityId
/**
* Append one output chunk. Offsets advance by the chunk's UTF-8 byte
* length; an empty chunk is dropped without waking observers.
* @param text - the chunk text, exactly as produced.
* @param options - stream label and gap marker.
*/
append(text: string, options?: ActivityAppendOptions): void
/**
* Replace the snapshot's status detail (a progress line such as `3/10`).
* @param detail - the new detail line.
*/
updateDetail(detail: string): void
/**
* Record the terminal outcome and trim retention to the settled cap.
* First-wins: a later call — the producer's own report after a teardown
* force-end, or a duplicate — is dropped silently.
* @param outcome - terminal status and optional detail.
*/
end(outcome: ActivityOutcome): void
}
/**
* A read-only projection of one activity, safe to hand to listeners and
* consumers — a fresh object per call, never live registry state.
*/
export interface ActivitySnapshot {
/** The registry-issued id (`<kind>-N`). */
id: ActivityId
/** The producer kind the process was opened with. */
kind: ActivityKind
/** The producer-supplied one-line label. */
label: string
/** Owner session id used for visibility and grouping; absent for unowned activities. */
ownerSession?: SessionId
/** Links to the tool call and background job for the same work, when supplied. */
correlation?: ActivityCorrelation
/** Current lifecycle state. */
status: ActivityStatus
/** Kind-specific status detail, when the producer supplied one. */
detail?: string
/** Epoch ms when the activity was opened. */
startedAt: number
/** Epoch ms when the activity ended; absent while `running`. */
finishedAt?: number
/** Total UTF-8 bytes ever appended — the offset the next chunk starts at. */
outputTotal: number
/**
* Offset of the oldest retained byte. Greater than zero exactly when
* retention dropped the head; a reader starting below it gets a lossy read.
*/
outputEarliest: number
}
/** One retained output chunk returned by {@link ActivityRegistry.read}. */
export interface ActivityOutputChunk {
/** Absolute offset of the chunk's first byte. */
at: number
/** Chunk text exactly as appended (possibly tail-trimmed by retention). */
text: string
/** Stream label, when the producer supplied one. */
channel?: ActivityChannel
/** Producer-reported loss immediately before this chunk. */
gapBefore?: true
}
/** Result of one non-consuming {@link ActivityRegistry.read}. */
export interface ActivityRead {
/** Retained chunks overlapping `[from, total)`, in offset order. */
chunks: readonly ActivityOutputChunk[]
/**
* Offset to resume from — the process' current `outputTotal`. Always a
* chunk boundary: appends land whole and trimming only advances chunk
* starts, and consumers concatenate `chunks` under that assumption, so a
* provider serving partial chunks would silently duplicate text.
*/
next: number
/** True when `from` fell below the oldest retained byte, so bytes are missing before `chunks`. */
lossy: boolean
}
/**
* Observer of visible-set changes: opening, detail updates, settlement, and
* removal. Receives the owner whose set changed, or `undefined` when an
* unowned process changed and every caller's set did. Consumers re-read
* rather than accumulating deltas.
*/
export type ActivitiesChangedListener = (owner: Agent | undefined) => void
/**
* Observer of stream advancement for one activity: new appended output, or the
* settlement that ends the stream. Carries no payload — a consumer schedules a
* {@link ActivityRegistry.read} from its own cursor.
*/
export type ActivityOutputListener = (id: ActivityId) => void
@@ -1,82 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import ActivityRegistry, { ActivityId } from '@deepseek-ai/dsh-activity'
import type { ActivitiesChangedListener, ActivitySnapshot } from '@deepseek-ai/dsh-activity'
import * as ProcessInvariant from '@deepseek-ai/dsh-activity/invariant'
import InvariantRegistry from '@deepseek-ai/dsh-invariants'
const BASE: ActivitySnapshot = {
id: ActivityId('bash-1'),
kind: 'bash',
label: 'compile',
status: 'completed',
startedAt: 10,
finishedAt: 20,
outputTotal: 12,
outputEarliest: 4,
}
const RUNNING: ActivitySnapshot = {
id: ActivityId('bash-1'),
kind: 'bash',
label: 'compile',
status: 'running',
startedAt: 10,
outputTotal: 0,
outputEarliest: 0,
}
/** Boot the companion over a probe registry whose visible set the test controls. */
async function setup(seed: ActivitySnapshot[] = []): Promise<(visible: ActivitySnapshot[], owner?: Agent) => void> {
const ctx = new Context()
let current = seed
let listener: ActivitiesChangedListener | undefined
const probe = {
list: () => current,
onActivitiesChanged(value: ActivitiesChangedListener) {
listener = value
return () => { listener = undefined }
},
} as unknown as ActivityRegistry
await ctx.plugin(InvariantRegistry)
await ctx.plugin({
name: 'activity-invariant-probe',
apply(child: Context) { child.provide('activities', probe) },
})
await ctx.plugin(ProcessInvariant)
if (listener === undefined) throw new Error('activity invariant did not subscribe to visible-set changes')
return (visible, owner) => {
current = visible
listener!(owner)
}
}
describe('process-registry invariants', () => {
it('accepts coherent running and terminal snapshots', async () => {
const notify = await setup([RUNNING])
expect(() => { notify([BASE]) }).not.toThrow()
expect(() => { notify([{ ...BASE, id: ActivityId('workflow-2'), kind: 'workflow' }]) })
.not.toThrow()
})
it.each([
[{ ...BASE, id: ActivityId('-1'), kind: '' as ActivitySnapshot['kind'] }, /positive ordinal/],
[{ ...BASE, id: ActivityId('other-1') }, /must be "bash-" followed by a positive ordinal/],
[{ ...BASE, id: ActivityId('bash-0') }, /positive ordinal/],
[{ ...BASE, label: '' }, /label must be non-empty/],
[{ ...BASE, startedAt: -1 }, /startedAt must be a non-negative epoch integer/],
[{ ...BASE, status: 'running' as const, finishedAt: 20 }, /finishedAt must be present exactly for a terminal status/],
[{ ...RUNNING, status: 'killed' as const }, /finishedAt must be present exactly for a terminal status/],
[{ ...BASE, finishedAt: 9 }, /no earlier than startedAt/],
[{ ...BASE, outputEarliest: 13 }, /0 <= outputEarliest <= outputTotal/],
[{ ...BASE, outputTotal: -1, outputEarliest: 0 }, /0 <= outputEarliest <= outputTotal/],
] as const)('rejects an incoherent registry snapshot', async (snapshot, message) => {
const notify = await setup()
expect(() => { notify([snapshot]) }).toThrow(message)
})
it('rejects an incoherent record already present at installation', async () => {
await expect(setup([{ ...BASE, label: '' }])).rejects.toThrow(/label must be non-empty/)
})
})
@@ -1,98 +0,0 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { SessionId } from '@deepseek-ai/dsh-session'
import ActivityRegistry, { ActivityId, pumpActivityOutput } from '@deepseek-ai/dsh-activity'
import type { ActivityAppendOptions, ActivityHandle, ActivityPumpSource, ActivitySnapshot } from '@deepseek-ai/dsh-activity'
/** Record every append so pump behavior is observable without a registry. */
function recordingHandle(): { handle: ActivityHandle; appends: { text: string; options?: ActivityAppendOptions }[] } {
const appends: { text: string; options?: ActivityAppendOptions }[] = []
const handle: ActivityHandle = {
id: ActivityId('bash-1'),
append(text, options) { appends.push({ text, ...options !== undefined ? { options } : {} }) },
updateDetail() {},
end() {},
}
return { handle, appends }
}
/** A scripted source: each read() shifts the next scripted result. */
function scriptedSource(
reads: { text: string; lossy?: boolean }[],
channel?: 'stdout' | 'stderr',
): { source: ActivityPumpSource; offsets: number[] } {
const offsets: number[] = []
let offset = 0
const source: ActivityPumpSource = {
...channel !== undefined ? { channel } : {},
read(fromByte) {
offsets.push(fromByte)
const next = reads.shift() ?? { text: '' }
offset += Buffer.byteLength(next.text, 'utf8')
return { text: next.text, nextOffset: offset, lossy: next.lossy ?? false }
},
}
return { source, offsets }
}
describe('ActivityRegistry seam', () => {
it('refuses to mount the abstract Service Definition directly', async () => {
const ctx = new Context()
await expect(ctx.plugin(ActivityRegistry as unknown as () => void))
.rejects.toThrow(/load an implementation such as @deepseek-ai\/dsh-activity-local/)
})
it('preserves the SessionId brand on public owner snapshots', () => {
expectTypeOf<ActivitySnapshot['ownerSession']>().toEqualTypeOf<SessionId | undefined>()
})
})
describe('pumpActivityOutput', () => {
it('rejects a non-positive poll interval before touching any source', async () => {
const { handle } = recordingHandle()
await expect(pumpActivityOutput(handle, [], { pollMs: 0, done: Promise.resolve() }))
.rejects.toThrow(/invalid pump pollMs/)
})
it('copies labeled deltas at the poll cadence and resumes each source at its own offset', async () => {
vi.useFakeTimers()
try {
const { handle, appends } = recordingHandle()
let settle!: () => void
const done = new Promise<void>((resolve) => { settle = resolve })
const out = scriptedSource([{ text: 'a' }, { text: 'bc' }], 'stdout')
const err = scriptedSource([{ text: '' }, { text: 'E' }], 'stderr')
const pump = pumpActivityOutput(handle, [out.source, err.source], { pollMs: 50, done })
await vi.advanceTimersByTimeAsync(50)
settle()
await pump
expect(appends).toEqual([
{ text: 'a', options: { channel: 'stdout' } },
{ text: 'bc', options: { channel: 'stdout' } },
{ text: 'E', options: { channel: 'stderr' } },
])
// Every read resumed from the previous nextOffset, never from a shared cursor.
expect(out.offsets).toEqual([0, 1, 3])
expect(err.offsets).toEqual([0, 0, 1])
// Settlement cleared the raced poll timer instead of leaving it pending.
expect(vi.getTimerCount()).toBe(0)
} finally {
vi.useRealTimers()
}
})
it('marks a lossy source read as a gap so observers see the discontinuity', async () => {
const { handle, appends } = recordingHandle()
const { source } = scriptedSource([{ text: 'tail', lossy: true }])
await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.resolve() })
expect(appends).toEqual([{ text: 'tail', options: { gapBefore: true } }])
})
it('treats a rejected done as settlement and still drains the final bytes', async () => {
const { handle, appends } = recordingHandle()
const { source } = scriptedSource([{ text: 'last' }])
await pumpActivityOutput(handle, [source], { pollMs: 1, done: Promise.reject(new Error('producer broke')) })
expect(appends).toEqual([{ text: 'last' }])
})
})
-36
View File
@@ -1,36 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cosmokit"
},
{
"path": "../../../vendor/cordis"
},
{
"path": "../../util/brand"
},
{
"path": "../../core/agent"
},
{
"path": "../../core/session"
},
{
"path": "../../llm/llm"
},
{
"path": "../../jobs/jobs"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/activity-controller/README.md
README.md: ffdf97aa94ab808ea4f95a9b8b8a36e3e04f43ed
README.zh.md: 580d00a11d3e8743d606c9c9ca98c4553235cc50
-106
View File
@@ -1,106 +0,0 @@
---
description: "Activity Remote streams for browsers: the activity.control roster stream, per-activity activity.observe output streams with flushMs coalescing and maxFrameBytes budgets, and the ctx.activityFeed client model."
kind: "package-reference"
---
# @deepseek-ai/dsh-api-activity-controller
English | [中文](README.zh.md)
## Summary
`dsh-api-activity-controller` streams the live-activity plane to browsers: a client follows one roster stream for every visible task row and opens one observation stream per expanded panel, resuming across reconnects from its own cursor. A pure read surface — it starts nothing, cancels nothing, and never touches the model-facing consuming cursors; the wire path reads only the registry's non-consuming offsets. A composition without `ctx.activities` still serves an empty baseline, so the client degrades to no live rows rather than an error.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Model Experience](#model-experience)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
Mount the host row beside `dsh-typert-registry` and load the client half through the plugin manifest; UI features then consume `ctx.activityFeed` instead of raw streams.
### The two Remote streams
The Host `ActivityController` (namespace `activity`) exposes two `@Remote({ mode: 'stream' })` methods:
- `control(signal)` — one complete roster baseline, then whole-bucket replacement frames per owner session on every registry change (the self-healing shape the session control stream uses for jobs). A composition without the registry serves an empty baseline. The Remote tier deliberately serves every session's rows to any connected browser, and `observe` satisfies the registry's owner fence with the mirrored owner: the per-session fence is an in-process contract, and this single-user local BFF reads across it exactly as the session control stream does for `jobsBySession`.
- `observe({ activityId, from? }, signal)` — one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget; one larger chunk ships whole), then one terminal `status` after the settled activity is drained, after which the generation closes normally. Reconnecting callers resume by passing the last frame's `next`; a resume behind the retained window arrives flagged `lossy`. Status rides the same stream as output, so settlement can never race a still-open output channel.
| Config | Default | Meaning |
|---|---|---|
| `flushMs` | `100` | Coalescing window after new output before an observation read. |
| `maxFrameBytes` | `65536` | Soft byte budget per observation output frame. |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-activity-controller) is the exhaustive source for every accepted field.
### The client feed
The client half (`./client`) installs `ctx.activityFeed`: `ClientActivityModel` mirrors the roster (last-wins buckets, generation replace) and accumulates each observed activity's bounded render tail, and `ClientActivityFeed.observe(id)` opens reference-counted observation streams that resume from the model's cursor across carrier generations.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
The Host side mirrors the registry into owner buckets (`ActivityFeed`), fans control frames out to per-generation followers, and runs each observation as an async generator that subscribes before its first read so no append lands between anchor and stream. The client side keeps observation state per activity with a resume cursor; release closures bind the exact entry they were minted for, so a superseded observation's late release can neither tear down nor clear its successor.
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | `ActivityController`: the two Remote stream methods |
| [`src/feed.ts`](src/feed.ts) | Owner-bucket roster mirror and control-stream followers |
| [`src/observe.ts`](src/observe.ts) | Per-activity observation generator: anchor, coalescing, terminal status |
| [`src/client/model.ts`](src/client/model.ts) | Roster and observation state with the bounded render tail |
| [`src/client/service.ts`](src/client/service.ts) | `ctx.activityFeed`: reference-counted observation streams |
| [`src/client/index.ts`](src/client/index.ts) | Client plugin: roster stream wiring and the service install |
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [`dsh-activity`](../../activity/activity/README.md) — the observation contract this transport reads.
- [`dsh-client-ui-activity`](../../client/ui-activity/README.md) — the task list rendering `ctx.activityFeed`.
- [docs/subsystems/activity.md](../../../docs/subsystems/activity.md) — the plane end to end, wire shapes included.
-----
<a id="model-experience"></a>
## Model Experience
None, as activity transport is browser and Host observation state and registers no prompt, tool, or session event.
#### KV Cache effect
None; the package never assembles or alters provider requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
These limits define when the transport is a poor fit. They are current package constraints, not a task backlog.
- **Registry presence is sampled at construction** — a registry mounted after the controller stays unobserved until the controller reloads.
- **The roster names no watermark** — an observation stream anchors itself; correlating roster rows against observation offsets is client policy.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
@@ -1,106 +0,0 @@
---
description: "面向浏览器的 Activity Remote 流:activity.control roster 流、带 flushMs 合并与 maxFrameBytes 预算的逐活动 activity.observe 输出流,以及 ctx.activityFeed 客户端模型。"
kind: "package-reference"
---
# @deepseek-ai/dsh-api-activity-controller
[English](README.md) | 中文
## 概述
`dsh-api-activity-controller` 把实时活动面流式提供给浏览器:客户端跟随一条 roster 流获得每个可见任务行,并为每个展开的面板打开一条观察流,重连后从自己的游标续传。它是纯读表面——什么都不启动、什么都不取消、从不触碰模型可见的消费游标;线路路径只读注册表的非消费偏移。没有 `ctx.activities` 的组合仍提供空 baseline,客户端退化为没有实时行而非报错。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [进一步探索](#further-exploration)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
在 `dsh-typert-registry` 旁挂载宿主行,并经插件清单加载客户端半侧;UI 功能随即消费 `ctx.activityFeed` 而非裸流。
### 两条 Remote 流
宿主 `ActivityController`(命名空间 `activity`)暴露两个 `@Remote({ mode: 'stream' })` 方法:
- `control(signal)`——一份完整 roster baseline,之后每次注册表变化按 owner 会话推送整桶替换帧(与 session control 流承载 jobs 的自愈形态一致)。没有注册表的组合返回空 baseline。Remote 层有意把所有会话的行提供给任何已连接的浏览器,`observe` 以镜像到的 owner 替调用方满足注册表的 owner 栅栏:按会话的栅栏是进程内契约,这个单用户本地 BFF 跨栅栏读取,与 session control 流对 `jobsBySession` 的做法完全一致。
- `observe({ activityId, from? }, signal)`——一帧 `opened` 锚点、合并后的 `output` 帧(`flushMs` 窗口,`maxFrameBytes` 软预算;单个更大的 chunk 整块发出),已结算活动排空后一帧终态 `status`,随后本代正常关闭。重连调用方传上一帧的 `next` 续传;落后于留存窗口的续传带 `lossy` 标记到达。status 与 output 走同一条流,结算绝不会与仍开放的输出通道赛跑。
| 配置 | 默认 | 含义 |
|---|---|---|
| `flushMs` | `100` | 新输出后进行一次观察读取前的合并窗口。 |
| `maxFrameBytes` | `65536` | 每个观察输出帧的软字节预算。 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-activity-controller)是每个可接受字段的穷尽来源。
### 客户端 feed
客户端半侧(`./client`)安装 `ctx.activityFeed`:`ClientActivityModel` 镜像 roster(last-wins 桶、按代整体替换)并累积每个被观察活动的有界渲染尾部,`ClientActivityFeed.observe(id)` 打开引用计数的观察流,跨 carrier 代从模型游标续传。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内幕——点击展开</summary>
宿主侧把注册表镜像进 owner 桶(`ActivityFeed`),向每代 follower 扇出 control 帧,并把每个观察跑成先订阅再首读的 async generator,锚点与流之间不会漏 append。客户端侧按活动保存观察状态与续传游标;release 闭包绑定自己创建时的那个条目,被替代观察的迟到 release 既拆不掉也清不掉后继者。
| 文件 | 角色 |
|---|---|
| [`src/index.ts`](src/index.ts) | `ActivityController`:两个 Remote 流方法 |
| [`src/feed.ts`](src/feed.ts) | owner 桶 roster 镜像与 control 流 follower |
| [`src/observe.ts`](src/observe.ts) | 逐活动观察 generator:锚点、合并、终态 status |
| [`src/client/model.ts`](src/client/model.ts) | roster 与观察状态,含有界渲染尾部 |
| [`src/client/service.ts`](src/client/service.ts) | `ctx.activityFeed`:引用计数的观察流 |
| [`src/client/index.ts`](src/client/index.ts) | 客户端插件:roster 流接线与服务安装 |
</details>
-----
<a id="further-exploration"></a>
## 进一步探索
- [`dsh-activity`](../../activity/activity/README.zh.md)——本传输所读的观察契约。
- [`dsh-client-ui-activity`](../../client/ui-activity/README.zh.md)——渲染 `ctx.activityFeed` 的任务列表。
- [docs/subsystems/activity.md](../../../docs/subsystems/activity.zh.md)——端到端的观察面,含线路形态。
-----
<a id="model-experience"></a>
## 模型体验
无:activity 传输是浏览器与宿主的观察状态,不注册任何 prompt、工具或会话事件。
#### KV Cache 影响
无;本包从不组装或改动 provider 请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
这些限制说明该传输何时不合适。它们是当前包约束,不是任务积压。
- **注册表存在性在构造时采样**——controller 之后才挂载的注册表在 controller 重载前保持不被观察。
- **roster 不命名水位**——观察流自行锚定;把 roster 行与观察偏移相关联是客户端策略。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
</details>
@@ -1,103 +0,0 @@
{
"name": "@deepseek-ai/dsh-api-activity-controller",
"description": "Activity Remote streams: the live-activity roster and per-activity non-consuming output observation",
"version": "0.1.2-alpha.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/api/activity-controller"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./types": {
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.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"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.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"
],
"license": "MIT",
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-activity": "workspace:^",
"@deepseek-ai/dsh-activity-local": "workspace:^",
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
},
"dsh": {
"client": {
"external": [
"@deepseek-ai/dsh-api-gateway/client"
],
"inject": [
"@deepseek-ai/dsh-api-gateway",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web"
}
}
}
@@ -1,86 +0,0 @@
/** Activity-specific adapter for the Gateway-owned snapshot stream lifecycle. */
import type { Context } from '@deepseek-ai/cordis'
import {
RemoteSnapshotStream,
RemoteStreamCarrierError,
type ClientRemote,
} from '@deepseek-ai/dsh-api-gateway/client'
import type { ActivityControlFrame } from '../types.ts'
import { ClientActivityModel } from './model.ts'
import { ClientActivityFeed } from './service.ts'
import type { ActivityRemote } from './service.ts'
export type { ActivityFeedPhase, ActivityFeedSnapshot, ObservedActivity } from './model.ts'
export type { ActivityFeedSource, ActivityRemote, IActivityFeed } from './service.ts'
export type { ActivityId } from '@deepseek-ai/dsh-activity/brand'
export type { ActivityRow } from '../types.ts'
type ActivityStreamRemote = Pick<ClientRemote, '$stream'> & { readonly activity: ActivityRemote }
type ActivityBaselineFrame = Extract<ActivityControlFrame, { type: 'baseline' }>
type ActivityRowsFrame = Extract<ActivityControlFrame, { type: 'rows' }>
declare module '@deepseek-ai/cordis' {
interface Context {
/** React-free client activity roster and observation control. */
activityFeed: import('./service.ts').IActivityFeed
}
}
/** Required Client Remote services. */
export const inject = ['remote', 'remote.activity']
/**
* Install the client activity model, its service face, and the reconnecting
* roster stream.
* @param ctx - client root Context.
*/
export function apply(ctx: Context): void {
const raw = ctx.remote as ActivityStreamRemote
// Resolve the child namespace to a concrete value while this plugin's
// context is current: later stream (re)opens run on caller stacks (a React
// event, a carrier retry) whose dynamic context has not declared
// `remote.activity`, and a property access there fails the inject check.
const remote: ActivityStreamRemote = {
$stream: options => raw.$stream(options),
activity: raw.activity,
}
const model = new ClientActivityModel()
new ClientActivityFeed(ctx, remote, model)
const control = createActivityRosterStream(remote, model)
control.start()
ctx.effect(
() => async () => { await control.dispose() },
'activity-controller.client.control',
)
}
/**
* Create the reconnecting roster stream feeding one client model.
* @param remote - generated activity namespace and Gateway stream factory.
* @param model - roster sink.
* @returns an unstarted stream owned by the client activity runtime.
*/
export function createActivityRosterStream(
remote: ActivityStreamRemote,
model: ClientActivityModel,
): RemoteSnapshotStream<ActivityBaselineFrame, ActivityRowsFrame> {
const stream = remote.$stream<ActivityControlFrame>({
name: 'activity roster stream',
open: signal => remote.activity.control(signal),
ended: accepted => accepted
? new RemoteStreamCarrierError('activity roster stream ended without a terminal result')
: new Error('activity roster stream ended before its opening baseline'),
})
return new RemoteSnapshotStream<ActivityBaselineFrame, ActivityRowsFrame>(stream, {
name: 'activity roster stream',
isSnapshot: (frame): frame is ActivityBaselineFrame => frame.type === 'baseline',
replace: (frame) => { model.replaceBaseline(frame.activities) },
update: (frame) => { model.replaceBucket(frame.sessionId, frame.activities) },
failed: () => {
// The previous roster stays published while the stream retries; a
// terminal protocol failure leaves the last snapshot in place.
},
})
}
@@ -1,216 +0,0 @@
/** Client-side activity state model shared by Remote transport and UI projection. */
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type { ActivityId } from '@deepseek-ai/dsh-activity/brand'
import type { ActivityObserveFrame, ActivityRow } from '../types.ts'
/** Bounded per-activity render tail, in UTF-16 code units. */
const RENDER_TAIL_LIMIT = 128 * 1024
/** Roster arrival lifecycle: `pending` until the first baseline lands. */
export type ActivityFeedPhase = 'pending' | 'ready'
/** One observed activity's live view state. */
export interface ObservedActivity {
readonly activityId: ActivityId
/** Accumulated output tail, bounded to the render limit. */
readonly text: string
/** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */
readonly gapBefore: boolean
/** Latest status delivered on the observation stream. */
readonly status: ActivityRow['status']
readonly detail?: string
/** True while the observation stream is open and the activity has not settled. */
readonly streaming: boolean
/** Terminal observation failure, when the stream ended abnormally. */
readonly error?: string
}
/** Immutable client activity state. */
export interface ActivityFeedSnapshot {
/** Roster rows per owner session; the empty key holds unowned activities. */
readonly rowsBySession: Readonly<Record<string, readonly ActivityRow[]>>
/** Live observation state keyed by activity id. */
readonly observed: Readonly<Record<string, ObservedActivity>>
readonly phase: ActivityFeedPhase
}
const UNOWNED = ''
/** Mutable observation bookkeeping behind one {@link ObservedActivity} view. */
interface ObservedState {
view: ObservedActivity
/** Resume offset for the next generation's `from`. */
cursor: number | undefined
}
/**
* Owns the client roster mirror and per-activity observation state. Pure data
* plus subscriptions — transport wiring stays in the client plugin, UI stays
* in slot components.
*/
export class ClientActivityModel {
private rowsBySession: Readonly<Record<string, readonly ActivityRow[]>> = {}
private readonly observedStates = new Map<string, ObservedState>()
private phase: ActivityFeedPhase = 'pending'
private readonly listeners = new Set<() => void>()
private snapshotCache: ActivityFeedSnapshot = { rowsBySession: {}, observed: {}, phase: 'pending' }
private snapshotDirty = false
/**
* Read the identity-stable current snapshot.
* @returns the cached snapshot, rebuilt only after a change.
*/
getSnapshot(): ActivityFeedSnapshot {
if (this.snapshotDirty) {
const observed: Record<string, ObservedActivity> = {}
for (const [id, state] of this.observedStates) observed[id] = state.view
this.snapshotCache = { rowsBySession: this.rowsBySession, observed, phase: this.phase }
this.snapshotDirty = false
}
return this.snapshotCache
}
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* Replace the whole roster from a generation baseline.
* @param rows - every visible row across all owner buckets.
*/
replaceBaseline(rows: readonly ActivityRow[]): void {
const next: Record<string, ActivityRow[]> = {}
for (const row of rows) {
const key = row.sessionId ?? UNOWNED
;(next[key] ??= []).push(row)
}
this.rowsBySession = next
this.phase = 'ready'
this.changed()
}
/**
* Replace one owner bucket wholesale; an empty replacement drops the key.
* @param sessionId - owner session, or undefined for the unowned bucket.
* @param rows - the bucket's complete visible rows.
*/
replaceBucket(sessionId: string | undefined, rows: readonly ActivityRow[]): void {
const key = sessionId ?? UNOWNED
if (rows.length === 0) {
this.rowsBySession = Object.fromEntries(
Object.entries(this.rowsBySession).filter(([bucket]) => bucket !== key))
} else {
this.rowsBySession = { ...this.rowsBySession, [key]: rows }
}
this.changed()
}
/**
* The resume offset for one activity's next observation generation.
* @param id - observed activity.
* @returns the last accepted `next`, or undefined for a fresh observation.
*/
cursorOf(id: ActivityId): number | undefined {
return this.observedStates.get(String(id))?.cursor
}
/**
* Install or reset observation state when a generation's anchor arrives.
* @param id - observed activity.
* @param frame - the generation's `opened` anchor.
*/
observeOpened(id: ActivityId, frame: Extract<ActivityObserveFrame, { type: 'opened' }>): void {
const existing = this.observedStates.get(String(id))
// A fresh view anchored past offset zero starts after an evicted head
// (fresh observations anchor at the registry's earliest retained byte), so
// it owes the same gap mark a live observer earned from lossy reads. A
// resume that already accumulated text keeps its recorded gap state.
const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0
const view: ObservedActivity = {
activityId: id,
text: existing?.view.text ?? '',
gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: true,
}
this.observedStates.set(String(id), { view, cursor: frame.from })
this.changed()
}
/**
* Append one output frame's chunks to the bounded render tail.
* @param id - observed activity.
* @param frame - a coalesced `output` frame.
*/
observeOutput(id: ActivityId, frame: Extract<ActivityObserveFrame, { type: 'output' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('')
let gapBefore = state.view.gapBefore || frame.lossy === true
|| frame.chunks.some(chunk => chunk.gapBefore === true)
if (text.length > RENDER_TAIL_LIMIT) {
let cut = text.length - RENDER_TAIL_LIMIT
// Never split a surrogate pair at the render bound.
const unit = text.charCodeAt(cut)
if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1
text = text.slice(cut)
gapBefore = true
}
state.view = { ...state.view, text, gapBefore }
state.cursor = frame.next
this.changed()
}
/**
* Record the terminal status delivered at the end of the observation stream.
* @param id - observed activity.
* @param frame - the terminal `status` frame.
*/
observeStatus(id: ActivityId, frame: Extract<ActivityObserveFrame, { type: 'status' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
state.view = {
...state.view,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: false,
}
this.changed()
}
/**
* Record a terminal observation failure.
* @param id - observed activity.
* @param error - the stream's terminal failure.
*/
observeFailed(id: ActivityId, error: unknown): void {
const state = this.observedStates.get(String(id))
if (state === undefined) return
state.view = { ...state.view, streaming: false, error: String(error) }
this.changed()
}
/**
* Drop observation state after the last observer stops.
* @param id - the no-longer-observed activity.
*/
observeStopped(id: ActivityId): void {
if (!this.observedStates.delete(String(id))) return
this.changed()
}
private changed(): void {
this.snapshotDirty = true
notifySubscribers(this.listeners, 'activity feed')
}
}
@@ -1,164 +0,0 @@
/** React-free client activity service: roster snapshot plus observation control. */
import { Service, type Context } from '@deepseek-ai/cordis'
import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
import type {} from '@deepseek-ai/dsh-api-activity-controller/remote'
import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol'
import type { ActivityId } from '@deepseek-ai/dsh-activity/brand'
import type { ActivityObserveFrame } from '../types.ts'
import type { ActivityFeedSnapshot, ClientActivityModel } from './model.ts'
/** Complete generated `ctx.remote.activity` namespace. */
export type ActivityRemote = TypertClientRemote['activity']
/** Bare observable source for the client activity snapshot. */
export interface ActivityFeedSource {
/** Read the identity-stable current snapshot. */
getSnapshot(): ActivityFeedSnapshot
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void
}
/** The activity feed's client service face. */
export interface IActivityFeed {
/** Roster rows, observation state, and arrival phase. */
readonly state: ActivityFeedSource
/**
* Start observing one activity's live output; reference-counted, so two
* viewers of the same activity share one stream.
* @param id - activity to observe.
* @returns stop function releasing this observer's reference.
*/
observe(id: ActivityId): () => void
}
/** One reference-counted observation stream. */
interface ObservationEntry {
refs: number
stopped: boolean
dispose: () => Promise<void>
}
/** Remote face the observation runner drives. */
type ActivityStreamRemote = Pick<ClientRemote, '$stream'> & { readonly activity: ActivityRemote }
/** Owns the bare activity snapshot and per-activity observation streams. */
export class ClientActivityFeed extends Service implements IActivityFeed {
readonly state: ActivityFeedSource
private readonly entries = new Map<string, ObservationEntry>()
/**
* @param ctx - client root Context.
* @param remote - generated activity namespace plus the Gateway stream factory.
* @param model - shared client activity model.
*/
constructor(
ctx: Context,
private readonly remote: ActivityStreamRemote,
private readonly model: ClientActivityModel,
) {
super(ctx, 'activityFeed')
this.state = model
ctx.effect(() => () => {
const open = [...this.entries.values()]
this.entries.clear()
for (const entry of open) {
entry.stopped = true
void entry.dispose()
}
}, 'activity-controller.client.observations')
}
observe(id: ActivityId): () => void {
const key = String(id)
const existing = this.entries.get(key)
if (existing !== undefined && !existing.stopped) {
existing.refs += 1
return this.releaser(key, existing)
}
const entry = this.startObservation(id)
this.entries.set(key, entry)
return this.releaser(key, entry)
}
/**
* Release closures bind the exact entry they were minted for, never the
* map's current occupant: a later `observe()` on the same id may have
* replaced a stopped entry, and decrementing or disposing through the key
* alone would tear down that newer stream's references.
*/
private releaser(key: string, entry: ObservationEntry): () => void {
let released = false
return () => {
if (released) return
released = true
entry.refs -= 1
if (entry.refs > 0) return
if (this.entries.get(key) === entry) this.entries.delete(key)
entry.stopped = true
void entry.dispose().then(() => {
// Clear the view only while no successor observation holds the key:
// a re-expand inside the dispose round-trip already re-anchored the
// model, and a stale clear would blank its panel for good.
if (this.entries.has(key)) return
// The key is the stringified branded id this releaser was minted for.
this.model.observeStopped(key as ActivityId)
})
}
}
private startObservation(id: ActivityId): ObservationEntry {
const name = `activity observation ${String(id)}`
const stream = this.remote.$stream<ActivityObserveFrame>({
name,
open: (signal) => {
const from = this.model.cursorOf(id)
return this.remote.activity.observe(
{ activityId: id, ...from !== undefined ? { from } : {} },
signal,
)
},
// A premature end after the anchor is retryable (a Host reload closes the
// generation); resuming from the cursor loses nothing. An end before the
// anchor is terminal.
ended: accepted => accepted
? new RemoteStreamCarrierError(`${name} ended before settlement`)
: new Error(`${name} ended before its anchor`),
})
const entry: ObservationEntry = {
refs: 1,
stopped: false,
dispose: () => stream.dispose(),
}
void (async () => {
try {
for await (const item of stream) {
const frame = item.value
if (frame.type === 'opened') {
this.model.observeOpened(id, frame)
item.accept()
continue
}
if (frame.type === 'output') {
this.model.observeOutput(id, frame)
continue
}
// Terminal status: leave the loop before the generation end is
// classified, then close the stream for good.
this.model.observeStatus(id, frame)
break
}
} catch (error) {
if (!entry.stopped) this.model.observeFailed(id, error)
} finally {
entry.stopped = true
void entry.dispose()
}
})()
return entry
}
}
@@ -1,184 +0,0 @@
/** Reconnect-safe activity roster producer over the optional `ctx.activities` registry. */
import type { Context } from '@deepseek-ai/cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { ActivityRegistry, ActivitySnapshot } from '@deepseek-ai/dsh-activity'
import type { ActivityControlFrame, ActivityRow } from './types.ts'
/**
* Project one registry snapshot into its browser-safe roster row.
* @param snapshot - fresh registry snapshot.
* @returns detached wire row for Remote consumers.
*/
export function activityRow(snapshot: ActivitySnapshot): ActivityRow {
return {
id: snapshot.id,
kind: snapshot.kind,
label: snapshot.label,
...snapshot.ownerSession !== undefined ? { sessionId: snapshot.ownerSession } : {},
...snapshot.correlation !== undefined
? {
correlation: {
...snapshot.correlation.callId !== undefined ? { callId: snapshot.correlation.callId } : {},
...snapshot.correlation.jobId !== undefined ? { jobId: snapshot.correlation.jobId } : {},
},
}
: {},
status: snapshot.status,
...snapshot.detail !== undefined ? { detail: snapshot.detail } : {},
startedAt: snapshot.startedAt,
...snapshot.finishedAt !== undefined ? { finishedAt: snapshot.finishedAt } : {},
outputTotal: snapshot.outputTotal,
}
}
/** One owner bucket: the exact live owner used for fenced reads plus its rows. */
interface OwnerBucket {
/** Exact registered Agent; undefined for the unowned bucket. */
readonly owner: Agent | undefined
rows: readonly ActivityRow[]
}
const UNOWNED = ''
/**
* Owns registry observation, the owner-bucketed roster mirror, and all active
* control-stream generations. The mirror also answers `ownerOf`, which
* observation streams use to read owned activities through the visibility
* fence with the exact owner the registry announced.
*/
export class ActivityFeed {
private readonly followers = new Set<ActivityFollower>()
private readonly buckets = new Map<string, OwnerBucket>()
private readonly registry: ActivityRegistry | undefined
/** @param ctx - Host context; the activity registry stays optional. */
constructor(ctx: Context) {
this.registry = ctx.get('activities')
if (this.registry === undefined) return
const registry = this.registry
this.refresh(undefined)
for (const agent of ctx.get('agents')?.list() ?? []) this.refresh(agent)
registry.onActivitiesChanged((owner) => {
this.refresh(owner)
const bucket = this.buckets.get(owner?.id ?? UNOWNED)
this.publish({
type: 'rows',
...owner !== undefined ? { sessionId: owner.id } : {},
activities: bucket?.rows ?? [],
})
})
ctx.effect(() => () => {
for (const follower of this.followers) follower.close()
this.followers.clear()
}, 'activity-controller.feed')
}
/**
* Read the complete current roster synchronously.
* @returns every visible row across all owner buckets.
*/
baseline(): readonly ActivityRow[] {
return [...this.buckets.values()].flatMap(bucket => bucket.rows)
}
/**
* Resolve the exact owner the registry announced for one activity, for
* fenced reads. `undefined` covers both unowned activities and ids this
* mirror has never seen — the registry itself settles unknown ids.
* @param id - activity id to resolve.
* @returns the exact live owner, or undefined.
*/
ownerOf(id: string): Agent | undefined {
for (const bucket of this.buckets.values()) {
if (bucket.rows.some(row => String(row.id) === id)) return bucket.owner
}
return undefined
}
/**
* Open one generation beginning with a complete baseline.
* @param signal - generation cancellation.
* @returns baseline followed by whole-bucket replacement frames.
*/
async *follow(signal: AbortSignal): AsyncIterable<ActivityControlFrame> {
signal.throwIfAborted()
const follower = new ActivityFollower()
this.followers.add(follower)
try {
yield { type: 'baseline', activities: this.baseline() }
yield* follower.read(signal)
} finally {
this.followers.delete(follower)
follower.close()
}
}
/** Re-read one owner's visible rows into its bucket; an emptied bucket is dropped. */
private refresh(owner: Agent | undefined): void {
/* v8 ignore next -- refresh is only reachable through a live registry's change feed. */
if (this.registry === undefined) return
const rows = this.registry.list(owner)
.filter(snapshot => owner === undefined
? snapshot.ownerSession === undefined
: snapshot.ownerSession === owner.id)
.map(activityRow)
const key = owner?.id ?? UNOWNED
if (rows.length === 0) {
this.buckets.delete(key)
return
}
this.buckets.set(key, { owner, rows })
}
private publish(frame: Exclude<ActivityControlFrame, { readonly type: 'baseline' }>): void {
for (const follower of this.followers) follower.push(frame)
}
}
/* jscpd:ignore-start -- mirrors WorkspaceFeed's follower fan-out by design; both serve baseline-plus-increment Remote streams. */
class ActivityFollower {
private readonly frames: ActivityControlFrame[] = []
private waiting: (() => void) | undefined
private closed = false
push(frame: ActivityControlFrame): void {
/* v8 ignore next -- closed followers are removed before later publication can reach them. */
if (this.closed) return
this.frames.push(frame)
this.waiting?.()
}
close(): void {
if (this.closed) return
this.closed = true
this.waiting?.()
}
async *read(signal: AbortSignal): AsyncIterable<ActivityControlFrame> {
while (!this.closed && !signal.aborted) {
const frame = this.frames.shift()
if (frame !== undefined) {
yield frame
continue
}
await this.wait(signal)
}
}
private wait(signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const finish = (): void => {
signal.removeEventListener('abort', finish)
/* v8 ignore next -- one read owns the sole installed wait callback. */
if (this.waiting === finish) this.waiting = undefined
resolve()
}
this.waiting = finish
signal.addEventListener('abort', finish, { once: true })
/* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */
if (signal.aborted || this.closed || this.frames.length > 0) finish()
})
}
}
/* jscpd:ignore-end */
@@ -1,104 +0,0 @@
/**
* Host Activity Remote owner: the live-activity roster stream and per-activity
* output observation. A pure read surface over the optional `ctx.activities`
* registry — it starts nothing, cancels nothing, and never touches the
* model-facing consuming cursors. Compositions without the registry serve an
* empty roster and refuse observation with a stable error.
*/
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import type {} from '@deepseek-ai/dsh-activity'
import { ActivityFeed } from './feed.ts'
import { observeActivity } from './observe.ts'
import type { ActivityControlFrame, ActivityObserveFrame, ActivityObserveRequest } from './types.ts'
export type * from './types.ts'
/** Default coalescing window between observation reads, in milliseconds. */
const DEFAULT_FLUSH_MS = 100
/** Default soft byte budget per observation output frame. */
const DEFAULT_MAX_FRAME_BYTES = 64 * 1024
/** Configuration for the Activity Remote owner. */
export interface Config {
/** Coalescing window after new output before an observation read, in milliseconds (default 100). */
flushMs?: number
/** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */
maxFrameBytes?: number
}
declare module '@deepseek-ai/cordis' {
interface Context {
/** Host Activity read API and Remote namespace owner. */
activityController: ActivityController
}
}
/** Host service backing the generated `ctx.remote.activity` namespace. */
export class ActivityController extends TypertRemoteService {
static inject = ['typert']
static Config: z<Config> = z.object({
flushMs: z.number()
.step(1)
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(DEFAULT_FLUSH_MS),
maxFrameBytes: z.number()
.step(1)
.min(1)
.max(Number.MAX_SAFE_INTEGER)
.default(DEFAULT_MAX_FRAME_BYTES),
})
private readonly feed: ActivityFeed
private readonly flushMs: number
private readonly maxFrameBytes: number
/**
* @param ctx - Host context; the activity registry stays optional.
* @param config - validated coalescing and framing bounds.
*/
constructor(ctx: Context, config: Config) {
super(ctx, 'activityController', { namespace: 'activity' })
this.flushMs = (config as Required<Config>).flushMs
this.maxFrameBytes = (config as Required<Config>).maxFrameBytes
this.feed = new ActivityFeed(ctx)
}
/**
* Stream a complete roster baseline followed by whole-bucket replacements.
* @param signal - generation cancellation.
* @returns baseline followed by per-owner roster replacement frames.
*/
@Remote({ mode: 'stream' })
control(signal: AbortSignal): AsyncIterable<ActivityControlFrame> {
return this.feed.follow(signal)
}
/**
* Stream one activity's retained output from an absolute byte offset, then
* its terminal status once settled and drained. Non-consuming: the
* model-facing cursors never observe these reads.
* @param request - target activity and optional resume offset.
* @param signal - generation cancellation.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' })
observe(request: ActivityObserveRequest, signal: AbortSignal): AsyncIterable<ActivityObserveFrame> {
const registry = this.ctx.get('activities')
if (registry === undefined) {
throw new Error('activity observation unavailable: no activity registry is loaded (load @deepseek-ai/dsh-activity-local)')
}
return observeActivity(registry, request, {
flushMs: this.flushMs,
maxFrameBytes: this.maxFrameBytes,
ownerOf: id => this.feed.ownerOf(id),
}, signal)
}
}
export default ActivityController
@@ -1,23 +0,0 @@
/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-activity-controller/invariant */
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-api-activity-controller'
/** Cordis companion plugin name. */
export const name = 'api-activity-controller-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: `@deepseek-ai/dsh-activity/invariant` owns snapshot relations; every roster
* generation is a full projection and observation reads are validated by the registry itself.
*/
const install: InvariantInstaller = () => {}
/** Register this package's invariant companion. */
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
@@ -1,104 +0,0 @@
/** Browser-safe roster and observation vocabulary for the Activity Remote. */
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { ActivityId } from '@deepseek-ai/dsh-activity/brand'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
export type { ActivityId } from '@deepseek-ai/dsh-activity/brand'
/** Lifecycle state carried on the wire; mirrors the registry's status union. */
export type ActivityRowStatus = 'running' | 'completed' | 'failed' | 'killed'
/** Links one roster row to the tool call and background job for the same work. */
export interface ActivityRowCorrelation {
/** Tool call that started the work, when a tool produced it. */
readonly callId?: ToolCallId
/** Background job carrying the model-facing control surface, when one exists. */
readonly jobId?: JobId
}
/**
* Browser-safe activity row. `kind` is `string` on the wire because the kind
* map is merge-extensible by producer plugins; presentation falls through a
* documented default for an unrecognized kind.
*/
export interface ActivityRow {
readonly id: ActivityId
readonly kind: string
readonly label: string
/** Owning session for grouping; absent for an unowned activity, visible to every session. */
readonly sessionId?: SessionId
readonly correlation?: ActivityRowCorrelation
readonly status: ActivityRowStatus
readonly detail?: string
readonly startedAt: number
readonly finishedAt?: number
/** Total UTF-8 bytes the activity has produced so far. */
readonly outputTotal: number
}
/**
* Roster stream frames. Each generation starts with exactly one complete
* baseline; later frames replace one owner bucket wholesale (absent
* `sessionId` = the unowned bucket), so start, settlement, removal, and a
* second browser tab all converge through one authoritative value.
*/
export type ActivityControlFrame =
| { readonly type: 'baseline'; readonly activities: readonly ActivityRow[] }
| { readonly type: 'rows'; readonly sessionId?: SessionId; readonly activities: readonly ActivityRow[] }
/** Opens one observation stream over a single activity's retained output. */
export interface ActivityObserveRequest {
readonly activityId: ActivityId
/**
* Absolute byte offset to resume from — a previous frame's `next`. Omitted
* starts at the oldest retained byte.
*/
readonly from?: number
}
/** One retained output chunk on the wire. `channel` widens to string like `kind`. */
export interface ActivityWireChunk {
/** Absolute offset of the chunk's first byte. */
readonly at: number
readonly text: string
readonly channel?: string
/** Bytes immediately before this chunk were lost at the producer or to retention. */
readonly gapBefore?: true
}
/**
* Observation stream frames: one `opened` anchor, then coalesced `output`
* batches, then — once the activity has settled and its retained output is
* drained — one terminal `status`, after which the stream closes normally.
* Status rides the same stream as output so settlement can never race a
* still-open output channel.
*/
export type ActivityObserveFrame =
| {
readonly type: 'opened'
readonly activityId: ActivityId
/** Offset the first `output` frame continues from. */
readonly from: number
/** Oldest retained byte at open time; `from < earliest` means the head is gone. */
readonly earliest: number
/** Total bytes produced at open time. */
readonly total: number
readonly status: ActivityRowStatus
readonly detail?: string
}
| {
readonly type: 'output'
readonly chunks: readonly ActivityWireChunk[]
/** Offset to resume from after this frame. */
readonly next: number
/** Bytes between the requested offset and `chunks` were already evicted. */
readonly lossy?: true
}
| {
readonly type: 'status'
readonly status: ActivityRowStatus
readonly detail?: string
readonly finishedAt?: number
}
@@ -1,306 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
import { ActivityId } from '@deepseek-ai/dsh-activity'
import { ActivityFeed } from '../src/feed.ts'
import { observeActivity } from '../src/observe.ts'
import type { ObserveOptions } from '../src/observe.ts'
import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts'
async function harness(withRegistry = true) {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
if (withRegistry) await ctx.plugin(LocalActivityRegistry, { retainBytes: 64, settledRetainBytes: 64 })
return ctx
}
function registerAgent(ctx: Context, rawId: string): Agent {
const scopeFiber = ctx.plugin(() => {})
const id = SessionId(rawId)
const session = Session.create(id)
const agent = {
id,
session,
inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }),
status: 'idle',
ctx: scopeFiber.ctx,
} as unknown as Agent
ctx.agents.register(agent)
return agent
}
/** Read frames until `count` arrive or the generator finishes, then abort. */
async function collect<Frame>(
iterable: AsyncIterable<Frame>,
count: number,
abort: AbortController,
): Promise<Frame[]> {
const frames: Frame[] = []
const iterator = iterable[Symbol.asyncIterator]()
try {
while (frames.length < count) {
const step = await iterator.next()
if (step.done) return frames
frames.push(step.value)
}
} finally {
abort.abort()
await iterator.next().catch(() => undefined)
}
return frames
}
const OBSERVE: Omit<ObserveOptions, 'ownerOf'> = { flushMs: 5, maxFrameBytes: 1024 }
describe('ActivityFeed roster', () => {
it('serves an empty baseline without the registry', async () => {
const ctx = await harness(false)
const feed = new ActivityFeed(ctx)
expect(feed.baseline()).toEqual([])
})
it('projects owned and unowned rows into the baseline with correlation and no live objects', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
ctx.activities.open({ kind: 'bash', label: 'unowned build' })
ctx.activities.open({
kind: 'bash',
label: 'owned build',
owner,
correlation: { jobId: 'bash-7' as never },
})
const feed = new ActivityFeed(ctx)
const rows = feed.baseline()
expect(rows).toHaveLength(2)
const owned = rows.find(row => row.sessionId !== undefined)
expect(owned?.sessionId).toBe('alice')
expect(owned?.correlation).toEqual({ jobId: 'bash-7' })
expect(owned?.status).toBe('running')
for (const row of rows) {
expect(Object.keys(row)).not.toContain('owner')
expect(row.outputTotal).toBe(0)
}
})
it('pushes whole-bucket frames for open, detail, settlement, and removal', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
const feed = new ActivityFeed(ctx)
const abort = new AbortController()
const frames = collect<ActivityControlFrame>(feed.follow(abort.signal), 4, abort)
await new Promise(resolve => setTimeout(resolve, 0))
const handle = ctx.activities.open({ kind: 'bash', label: 'work', owner })
handle.updateDetail('halfway')
handle.end({ status: 'completed', detail: 'exit code: 0' })
const seen = await frames
expect(seen[0]).toEqual({ type: 'baseline', activities: [] })
const rows = seen.slice(1) as Extract<ActivityControlFrame, { type: 'rows' }>[]
expect(rows.every(frame => frame.sessionId === 'alice')).toBe(true)
expect(rows[0]?.activities[0]?.status).toBe('running')
expect(rows[1]?.activities[0]?.detail).toBe('halfway')
expect(rows[2]?.activities[0]?.status).toBe('completed')
expect(feed.ownerOf(String(rows[0]!.activities[0]!.id))).toBe(owner)
})
it('projects callId-only correlation and serves rosters without an agent registry', async () => {
const bare = new Context()
await bare.plugin(LocalActivityRegistry)
bare.activities.open({ kind: 'bash', label: 'lone', correlation: { callId: 'call-1' as never } })
const feed = new ActivityFeed(bare)
expect(feed.baseline()[0]?.correlation).toEqual({ callId: 'call-1' })
})
it('pushes an empty bucket frame when owner disposal removes the last row', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
const feed = new ActivityFeed(ctx)
ctx.activities.open({ kind: 'bash', label: 'doomed', owner })
const abort = new AbortController()
const frames = collect<ActivityControlFrame>(feed.follow(abort.signal), 3, abort)
await new Promise(resolve => setTimeout(resolve, 0))
await (owner.ctx as unknown as { fiber: { dispose(): Promise<void> } }).fiber?.dispose?.()
// Owner-disposal removal reaches the feed as an emptied bucket.
await new Promise(resolve => setTimeout(resolve, 10))
abort.abort()
const seen = await frames
const last = seen.at(-1) as Extract<ActivityControlFrame, { type: 'rows' }> | undefined
if (last !== undefined && last.type === 'rows') expect(Array.isArray(last.activities)).toBe(true)
})
it('resolves undefined for unowned and unknown activities', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'shared' })
expect(feed.ownerOf(String(handle.id))).toBeUndefined()
expect(feed.ownerOf('bash-999')).toBeUndefined()
})
})
describe('observeActivity', () => {
async function observed(ctx: Context, feed: ActivityFeed, request: { activityId: ActivityId; from?: number }) {
const abort = new AbortController()
const frames: ActivityObserveFrame[] = []
const stream = observeActivity(
ctx.activities,
request,
{ ...OBSERVE, ownerOf: id => feed.ownerOf(id) },
abort.signal,
)
const done = (async () => {
for await (const frame of stream) frames.push(frame)
})()
return { abort, frames, done }
}
it('anchors, streams coalesced output, and closes with the terminal status', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
handle.append('early ')
const { frames, done } = await observed(ctx, feed, { activityId: handle.id })
await new Promise(resolve => setTimeout(resolve, 20))
handle.append('live', { channel: 'stderr' })
await new Promise(resolve => setTimeout(resolve, 20))
handle.end({ status: 'completed', detail: 'exit code: 0' })
await done
expect(frames[0]).toMatchObject({ type: 'opened', from: 0, earliest: 0, total: 6, status: 'running' })
const outputs = frames.filter(frame => frame.type === 'output')
const text = outputs.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('')
expect(text).toBe('early live')
expect(outputs.at(-1)?.next).toBe(10)
expect(outputs.flatMap(frame => frame.chunks).find(chunk => chunk.text === 'live')?.channel).toBe('stderr')
expect(frames.at(-1)).toMatchObject({ type: 'status', status: 'completed', detail: 'exit code: 0' })
})
it('resumes from an explicit offset and flags an evicted resume lossy', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'spam' })
handle.append('a'.repeat(60))
handle.append('b'.repeat(60))
handle.end({ status: 'completed' })
const fresh = await observed(ctx, feed, { activityId: handle.id, from: 0 })
await fresh.done
const output = fresh.frames.find(frame => frame.type === 'output')
expect(output?.lossy).toBe(true)
expect(output?.chunks.map(chunk => chunk.text).join('')).toBe('b'.repeat(60))
const resumed = await observed(ctx, feed, { activityId: handle.id, from: 120 })
await resumed.done
expect(resumed.frames.some(frame => frame.type === 'output')).toBe(false)
expect(resumed.frames.at(-1)?.type).toBe('status')
})
it('splits oversized reads along the frame budget', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const registry = ctx.activities
const handle = registry.open({ kind: 'bash', label: 'wide' })
const abort = new AbortController()
handle.append('x'.repeat(30))
handle.append('y'.repeat(30))
handle.end({ status: 'completed' })
const frames = await collect<ActivityObserveFrame>(
observeActivity(registry, { activityId: handle.id }, {
flushMs: 5,
maxFrameBytes: 30,
ownerOf: id => feed.ownerOf(id),
}, abort.signal),
5,
abort,
)
const outputs = frames.filter(frame => frame.type === 'output')
expect(outputs).toHaveLength(2)
expect(outputs[0]?.next).toBe(30)
expect(outputs[1]?.next).toBe(60)
})
it('carries the lossy flag on the first frame of a split evicted read', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const registry = ctx.activities
const handle = registry.open({ kind: 'bash', label: 'evicted split' })
handle.append('a'.repeat(40))
handle.append('b'.repeat(40))
handle.append('c'.repeat(40))
handle.end({ status: 'completed' })
const abort = new AbortController()
const frames = await collect<ActivityObserveFrame>(
observeActivity(registry, { activityId: handle.id, from: 0 }, {
flushMs: 5,
maxFrameBytes: 40,
ownerOf: id => feed.ownerOf(id),
}, abort.signal),
5,
abort,
)
const outputs = frames.filter(frame => frame.type === 'output')
expect(outputs[0]?.lossy).toBe(true)
expect(outputs[1]?.lossy).toBeUndefined()
})
it('reads owned activities through the roster owner and rejects bad offsets and unknown ids', async () => {
const ctx = await harness()
const owner = registerAgent(ctx, 'alice')
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'secret', owner })
handle.append('classified')
handle.end({ status: 'completed' })
const { frames, done } = await observed(ctx, feed, { activityId: handle.id })
await done
expect(frames.some(frame => frame.type === 'output'
&& frame.chunks.some(chunk => chunk.text === 'classified'))).toBe(true)
const abort = new AbortController()
await expect(collect(
observeActivity(ctx.activities, { activityId: handle.id, from: -1 }, {
...OBSERVE,
ownerOf: id => feed.ownerOf(id),
}, abort.signal),
1,
abort,
)).rejects.toThrow(/invalid observe offset/)
const abortUnknown = new AbortController()
await expect(collect(
observeActivity(ctx.activities, { activityId: ActivityId('bash-999') }, {
...OBSERVE,
ownerOf: id => feed.ownerOf(id),
}, abortUnknown.signal),
1,
abortUnknown,
)).rejects.toThrow(/unknown activity/)
})
it('ignores output signals for other activities while waiting', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const watched = ctx.activities.open({ kind: 'bash', label: 'watched' })
const noisy = ctx.activities.open({ kind: 'bash', label: 'noisy' })
const { abort, frames, done } = await observed(ctx, feed, { activityId: watched.id })
await new Promise(resolve => setTimeout(resolve, 15))
noisy.append('unrelated')
await new Promise(resolve => setTimeout(resolve, 15))
watched.end({ status: 'completed' })
await done
abort.abort()
expect(frames.filter(frame => frame.type === 'output')).toEqual([])
})
it('stops cleanly on abort while waiting for output', async () => {
const ctx = await harness()
const feed = new ActivityFeed(ctx)
const handle = ctx.activities.open({ kind: 'bash', label: 'idle' })
const { abort, frames, done } = await observed(ctx, feed, { activityId: handle.id })
await new Promise(resolve => setTimeout(resolve, 20))
abort.abort()
await done
expect(frames[0]?.type).toBe('opened')
expect(frames.at(-1)?.type).not.toBe('status')
})
})
@@ -1,113 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client'
import { apply, createActivityRosterStream } from '../src/client/index.ts'
import { ClientActivityModel } from '../src/client/model.ts'
import type { ActivityControlFrame, ActivityId, ActivityRow } from '../src/types.ts'
function row(): ActivityRow {
return {
id: 'bash-1' as ActivityId,
kind: 'bash',
label: 'build',
status: 'running',
startedAt: 1,
outputTotal: 0,
}
}
/** One scripted roster stream: frames pushed by the test, never reopened. */
class FakeStream {
disposed = false
private readonly frames: ActivityControlFrame[] = []
private waiter: (() => void) | undefined
private failure: Error | undefined
push(frame: ActivityControlFrame): void {
this.frames.push(frame)
this.waiter?.()
}
poison(error: Error): void {
this.failure = error
this.waiter?.()
}
dispose(): Promise<void> {
this.disposed = true
this.waiter?.()
return Promise.resolve()
}
async *[Symbol.asyncIterator]() {
while (!this.disposed) {
if (this.failure !== undefined) throw this.failure
const frame = this.frames.shift()
if (frame === undefined) {
await new Promise<void>((resolve) => { this.waiter = resolve })
continue
}
yield { generation: 1, value: frame, signal: new AbortController().signal, accept: () => {} }
}
}
}
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
describe('activity-controller client plugin', () => {
it('installs ctx.activityFeed and folds roster frames from the control stream', async () => {
const ctx = new Context()
const streams: { options: { open: (signal: AbortSignal) => unknown; ended: (accepted: boolean) => Error }; stream: FakeStream }[] = []
const controlCalls: AbortSignal[] = []
ctx.provide('remote', {
$stream: (options: never) => {
const stream = new FakeStream()
streams.push({ options, stream })
return stream
},
activity: {
control: (signal: AbortSignal) => {
controlCalls.push(signal)
return { [Symbol.asyncIterator]: async function* () { /* never yields */ } }
},
observe: () => { throw new Error('not exercised') },
},
} as never)
const fiber = ctx.plugin({ inject: ['remote'], apply })
await fiber
streams[0]!.stream.push({ type: 'baseline', activities: [row()] })
streams[0]!.stream.push({ type: 'rows', activities: [] })
await tick()
const snapshot = ctx.activityFeed.state.getSnapshot()
expect(snapshot.phase).toBe('ready')
expect(snapshot.rowsBySession['']).toBeUndefined()
// The generation opener and end classification belong to the domain.
streams[0]!.options.open(new AbortController().signal)
expect(controlCalls).toHaveLength(1)
expect(streams[0]!.options.ended(true)).toBeInstanceOf(RemoteStreamCarrierError)
expect(streams[0]!.options.ended(false)).toBeInstanceOf(Error)
// A terminal roster failure keeps the last snapshot in place.
streams[0]!.stream.poison(new Error('roster stream broke'))
await tick()
expect(ctx.activityFeed.state.getSnapshot().phase).toBe('ready')
await fiber.dispose()
expect(streams[0]!.stream.disposed).toBe(true)
})
it('keeps the last roster on a terminal stream failure', async () => {
const model = new ClientActivityModel()
const remote = {
$stream: () => new FakeStream(),
activity: { control: () => { throw new Error('unused') }, observe: () => { throw new Error('unused') } },
}
const stream = createActivityRosterStream(remote as never, model)
model.replaceBaseline([row()])
stream.start()
await stream.dispose()
expect(model.getSnapshot().rowsBySession['']?.[0]?.label).toBe('build')
})
})
@@ -1,183 +0,0 @@
import { describe, expect, it } from 'vitest'
import { ClientActivityModel } from '../src/client/model.ts'
import type { ActivityId, ActivityRow } from '../src/types.ts'
function row(over: Partial<ActivityRow> = {}): ActivityRow {
return {
id: 'bash-1' as ActivityId,
kind: 'bash',
label: 'build',
sessionId: 'alice' as ActivityRow['sessionId'],
status: 'running',
startedAt: 1,
outputTotal: 0,
...over,
} as ActivityRow
}
/** A row with no owner session, as the unowned bucket carries them. */
function unownedRow(over: Partial<ActivityRow> = {}): ActivityRow {
const { sessionId: _drop, ...rest } = row(over)
return rest
}
const ID = 'bash-1' as ActivityId
function opened(model: ClientActivityModel, over: Partial<{ from: number; earliest: number; total: number }> = {}): void {
model.observeOpened(ID, {
type: 'opened',
activityId: ID,
from: 0,
earliest: 0,
total: 0,
status: 'running',
...over,
})
}
describe('ClientActivityModel roster', () => {
it('folds baselines into per-session buckets and reports readiness', () => {
const model = new ClientActivityModel()
expect(model.getSnapshot().phase).toBe('pending')
model.replaceBaseline([row(), unownedRow({ id: 'workflow-1' as ActivityId })])
const snapshot = model.getSnapshot()
expect(snapshot.phase).toBe('ready')
expect(snapshot.rowsBySession['alice']).toHaveLength(1)
expect(snapshot.rowsBySession['']).toHaveLength(1)
})
it('replaces buckets wholesale and drops emptied keys', () => {
const model = new ClientActivityModel()
model.replaceBaseline([row()])
model.replaceBucket('alice', [row({ status: 'completed', finishedAt: 2 })])
expect(model.getSnapshot().rowsBySession['alice']?.[0]?.status).toBe('completed')
model.replaceBucket('alice', [])
expect('alice' in model.getSnapshot().rowsBySession).toBe(false)
model.replaceBucket(undefined, [unownedRow()])
expect(model.getSnapshot().rowsBySession['']).toHaveLength(1)
})
it('keeps snapshot identity stable between changes and notifies subscribers', () => {
const model = new ClientActivityModel()
let notified = 0
const unsubscribe = model.subscribe(() => { notified += 1 })
const before = model.getSnapshot()
expect(model.getSnapshot()).toBe(before)
model.replaceBaseline([row()])
expect(notified).toBe(1)
expect(model.getSnapshot()).not.toBe(before)
unsubscribe()
model.replaceBucket('alice', [])
expect(notified).toBe(1)
})
})
describe('ClientActivityModel observation', () => {
it('accumulates output, advances the resume cursor, and settles on status', () => {
const model = new ClientActivityModel()
opened(model)
expect(model.cursorOf(ID)).toBe(0)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'a' }, { at: 1, text: 'b' }], next: 2 })
model.observeOutput(ID, { type: 'output', chunks: [{ at: 2, text: 'c' }], next: 3 })
expect(model.cursorOf(ID)).toBe(3)
const view = model.getSnapshot().observed[String(ID)]
expect(view?.text).toBe('abc')
expect(view?.streaming).toBe(true)
model.observeStatus(ID, { type: 'status', status: 'completed', detail: 'exit code: 0' })
const settled = model.getSnapshot().observed[String(ID)]
expect(settled?.streaming).toBe(false)
expect(settled?.status).toBe('completed')
expect(settled?.detail).toBe('exit code: 0')
})
it('marks gaps from lossy frames, gap chunks, and a resume behind the retained head', () => {
const model = new ClientActivityModel()
opened(model, { from: 4, earliest: 8, total: 10 })
expect(model.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
const clean = new ClientActivityModel()
opened(clean)
clean.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'x', gapBefore: true }], next: 1 })
expect(clean.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
const lossy = new ClientActivityModel()
opened(lossy)
lossy.observeOutput(ID, { type: 'output', chunks: [], next: 5, lossy: true })
expect(lossy.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
})
it('marks a fresh observation anchored past the evicted head', () => {
// Fresh observations anchor at the registry's earliest retained byte, so
// from === earliest > 0 means the head was already discarded.
const fresh = new ClientActivityModel()
opened(fresh, { from: 60_240, earliest: 60_240, total: 321_328 })
expect(fresh.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
// A retry that accumulated no text yet earns the mark the same way.
const retried = new ClientActivityModel()
opened(retried)
expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(false)
opened(retried, { from: 6, earliest: 6, total: 6 })
expect(retried.getSnapshot().observed[String(ID)]?.gapBefore).toBe(true)
})
it('bounds the render tail without splitting a surrogate pair', () => {
const model = new ClientActivityModel()
opened(model)
const emoji = '😀'.repeat((64 * 1024) + 8)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: emoji }], next: emoji.length * 2 })
const view = model.getSnapshot().observed[String(ID)]
expect(view?.gapBefore).toBe(true)
expect(view!.text.length).toBeLessThanOrEqual(128 * 1024)
// The bound landed between pairs: the surviving text still round-trips.
expect(/^(?:😀)+$/u.test(view!.text)).toBe(true)
})
it('carries the anchor detail and trims a plain-ASCII tail without a boundary shift', () => {
const model = new ClientActivityModel()
model.observeOpened(ID, {
type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running', detail: 'exit soon',
})
expect(model.getSnapshot().observed[String(ID)]?.detail).toBe('exit soon')
const long = 'x'.repeat((128 * 1024) + 5)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: long }], next: long.length })
const view = model.getSnapshot().observed[String(ID)]
expect(view?.text.length).toBe(128 * 1024)
expect(view?.gapBefore).toBe(true)
})
it('advances the cut past a low surrogate landing exactly on the bound', () => {
const model = new ClientActivityModel()
opened(model)
// 'z' + one emoji + odd ASCII tail puts a low surrogate exactly at the cut index.
const text = 'z😀' + 'a'.repeat((128 * 1024) - 1)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text }], next: text.length })
const view = model.getSnapshot().observed[String(ID)]
expect(view?.text.length).toBe((128 * 1024) - 1)
expect(view?.text.startsWith('a')).toBe(true)
})
it('preserves accumulated text across a reconnect anchor and clears on stop', () => {
const model = new ClientActivityModel()
opened(model)
model.observeOutput(ID, { type: 'output', chunks: [{ at: 0, text: 'kept' }], next: 4 })
opened(model, { from: 4, earliest: 0, total: 4 })
expect(model.getSnapshot().observed[String(ID)]?.text).toBe('kept')
model.observeStopped(ID)
expect(model.getSnapshot().observed[String(ID)]).toBeUndefined()
expect(model.cursorOf(ID)).toBeUndefined()
// A second stop is inert.
model.observeStopped(ID)
})
it('records a terminal stream failure on the live view', () => {
const model = new ClientActivityModel()
opened(model)
model.observeFailed(ID, new Error('carrier gone'))
const view = model.getSnapshot().observed[String(ID)]
expect(view?.streaming).toBe(false)
expect(view?.error).toContain('carrier gone')
// A failure for an untracked id is inert.
model.observeFailed('bash-9' as ActivityId, new Error('ignored'))
})
})
@@ -1,276 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import { ClientActivityModel } from '../src/client/model.ts'
import { ClientActivityFeed } from '../src/client/service.ts'
import type { ActivityId, ActivityObserveFrame } from '../src/types.ts'
const ID = 'bash-1' as ActivityId
/** One scripted logical stream: frames are pushed by the test, never reopened. */
class FakeStream {
disposed = false
/** Model a carrier whose disposal surfaces as an iterator throw. */
throwOnDispose = false
/** Hold the dispose promise open until {@link releaseDispose}. */
deferDispose = false
private disposeRelease: (() => void) | undefined
private disposePending: Promise<void> | undefined
private readonly frames: ActivityObserveFrame[] = []
private waiter: (() => void) | undefined
private failure: Error | undefined
readonly accepts: number[] = []
push(frame: ActivityObserveFrame): void {
this.frames.push(frame)
this.waiter?.()
}
poison(error: Error): void {
this.failure = error
this.waiter?.()
}
dispose(): Promise<void> {
if (this.throwOnDispose && this.failure === undefined) {
this.failure = new Error('carrier tore down')
} else {
this.disposed = true
}
this.waiter?.()
if (this.deferDispose) {
// Repeat disposals (releaser plus the consumer's finally) share one
// deferred promise, so releaseDispose resumes every waiter.
this.disposePending ??= new Promise((resolve) => { this.disposeRelease = resolve })
return this.disposePending
}
return Promise.resolve()
}
releaseDispose(): void {
this.disposeRelease?.()
}
async *[Symbol.asyncIterator]() {
let generation = 1
while (!this.disposed) {
if (this.failure !== undefined) throw this.failure
const frame = this.frames.shift()
if (frame === undefined) {
await new Promise<void>((resolve) => { this.waiter = resolve })
continue
}
yield {
generation,
value: frame,
signal: new AbortController().signal,
accept: () => { this.accepts.push(generation) },
}
generation = 1
}
}
}
function bench() {
const ctx = new Context()
const model = new ClientActivityModel()
const streams: { options: { open: (signal: AbortSignal) => unknown }; stream: FakeStream }[] = []
const observeCalls: unknown[] = []
const remote = {
$stream: (options: { open: (signal: AbortSignal) => unknown }) => {
const stream = new FakeStream()
streams.push({ options, stream })
return stream
},
activity: {
observe: (request: unknown) => {
observeCalls.push(request)
return { [Symbol.asyncIterator]: async function* () { /* never yields */ } }
},
control: () => { throw new Error('not exercised') },
},
}
const feed = new ClientActivityFeed(ctx, remote as never, model)
return { ctx, model, feed, streams, observeCalls }
}
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
describe('ClientActivityFeed observation streams', () => {
it('feeds anchor, output, and terminal frames into the model, then closes', async () => {
const { model, feed, streams } = bench()
feed.observe(ID)
const { stream } = streams[0]!
stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
stream.push({ type: 'output', chunks: [{ at: 0, text: 'hi' }], next: 2 })
await tick()
expect(model.getSnapshot().observed[String(ID)]?.text).toBe('hi')
expect(stream.accepts).toHaveLength(1)
stream.push({ type: 'status', status: 'completed' })
await tick()
expect(model.getSnapshot().observed[String(ID)]?.streaming).toBe(false)
expect(stream.disposed).toBe(true)
})
it('shares one stream across observers and stops after the last release', async () => {
const { model, feed, streams } = bench()
const first = feed.observe(ID)
const second = feed.observe(ID)
expect(streams).toHaveLength(1)
streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
first()
first()
expect(streams[0]!.stream.disposed).toBe(false)
second()
await tick()
expect(streams[0]!.stream.disposed).toBe(true)
expect(model.getSnapshot().observed[String(ID)]).toBeUndefined()
})
it('a late release of a superseded observation neither tears down nor clears its successor', async () => {
const { model, feed, streams } = bench()
const firstStop = feed.observe(ID)
const first = streams[0]!.stream
first.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
first.push({ type: 'status', status: 'completed' })
await tick()
// A successor observation replaces the settled entry while the first
// observer still holds its releaser.
const secondStop = feed.observe(ID)
const second = streams[1]!.stream
second.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
firstStop()
await tick()
expect(second.disposed).toBe(false)
expect(model.getSnapshot().observed[String(ID)]).toBeDefined()
secondStop()
await tick()
expect(second.disposed).toBe(true)
expect(model.getSnapshot().observed[String(ID)]).toBeUndefined()
})
it('a re-observation inside the dispose round-trip keeps its fresh view', async () => {
const { model, feed, streams } = bench()
const stop = feed.observe(ID)
const first = streams[0]!.stream
first.deferDispose = true
first.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
stop()
// Re-expand while the previous generation's dispose is still in flight.
feed.observe(ID)
const second = streams[1]!.stream
second.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
first.releaseDispose()
await tick()
// The stale post-dispose clear must not blank the successor's view.
expect(model.getSnapshot().observed[String(ID)]).toBeDefined()
second.push({ type: 'output', chunks: [{ at: 0, text: 'alive' }], next: 5 })
await tick()
expect(model.getSnapshot().observed[String(ID)]?.text).toBe('alive')
})
it('opens a fresh stream for a re-observed activity and resumes from the model cursor', async () => {
const { feed, streams, observeCalls, model } = bench()
const stop = feed.observe(ID)
streams[0]!.stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
streams[0]!.stream.push({ type: 'output', chunks: [{ at: 0, text: 'abc' }], next: 3 })
await tick()
// The generation opener reads the live cursor at open time.
streams[0]!.options.open(new AbortController().signal)
expect(observeCalls.at(-1)).toEqual({ activityId: ID, from: 3 })
stop()
await tick()
feed.observe(ID)
expect(streams).toHaveLength(2)
streams[1]!.options.open(new AbortController().signal)
// Stopped observation dropped the cursor: the fresh stream starts unanchored.
expect(observeCalls.at(-1)).toEqual({ activityId: ID })
expect(model.cursorOf(ID)).toBeUndefined()
})
it('classifies a premature end as retryable only after the anchor', () => {
const { feed, streams } = bench()
feed.observe(ID)
const options = streams[0]!.options as unknown as { ended: (accepted: boolean) => Error }
expect(options.ended(true).name).toBe('RemoteStreamCarrierError')
expect(options.ended(false).name).toBe('Error')
})
it('records a consumer failure on the live view', async () => {
const { model, feed, streams } = bench()
feed.observe(ID)
const { stream } = streams[0]!
stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
stream.poison(new Error('frame decode broke'))
await tick()
const view = model.getSnapshot().observed[String(ID)]
expect(view?.error).toContain('frame decode broke')
expect(view?.streaming).toBe(false)
})
it('treats a release after service disposal as inert and stays silent for post-stop failures', async () => {
const { ctx, model, feed, streams } = bench()
const stop = feed.observe(ID)
const { stream } = streams[0]!
stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
// Poison wakes the consumer, but the synchronous stop lands first, so the
// failure arrives on an already-stopped entry and stays silent.
stream.poison(new Error('after stop'))
stop()
await tick()
expect(model.getSnapshot().observed[String(ID)]).toBeUndefined()
const second = feed.observe(ID)
await ctx.fiber.dispose()
// The disposal path already removed every entry; a live releaser whose
// entry is gone is inert.
second()
})
it('stays silent when the carrier teardown itself throws after a stop', async () => {
const { model, feed, streams } = bench()
const stop = feed.observe(ID)
const { stream } = streams[0]!
stream.throwOnDispose = true
stream.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
// The releaser marks the entry stopped, then its dispose surfaces as an
// iterator throw — which the consumer swallows for a stopped entry.
stop()
await tick()
expect(model.getSnapshot().observed[String(ID)]?.error).toBeUndefined()
})
it('records a terminal stream failure and service disposal closes live streams', async () => {
const { ctx, model, feed, streams } = bench()
feed.observe(ID)
const failing = streams[0]!.stream
failing.push({ type: 'opened', activityId: ID, from: 0, earliest: 0, total: 0, status: 'running' })
await tick()
const iterator = failing[Symbol.asyncIterator]()
void iterator
// Simulate a terminal consumer failure by disposing the underlying stream:
// the consumer loop ends without a status frame and cleans up.
const spy = vi.spyOn(model, 'observeFailed')
await failing.dispose()
await tick()
const other = 'bash-2' as ActivityId
feed.observe(other)
await ctx.fiber.dispose()
expect(streams.every(entry => entry.stream.disposed)).toBe(true)
expect(spy).not.toHaveBeenCalled()
})
})
@@ -1,102 +0,0 @@
import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import LocalActivityRegistry from '@deepseek-ai/dsh-activity-local'
import { ActivityController } from '../src/index.ts'
import type { ActivityControlFrame, ActivityObserveFrame } from '../src/types.ts'
async function collect<Frame>(
iterable: AsyncIterable<Frame>,
count: number,
abort: AbortController,
): Promise<Frame[]> {
const frames: Frame[] = []
const iterator = iterable[Symbol.asyncIterator]()
try {
while (frames.length < count) {
const step = await iterator.next()
if (step.done) return frames
frames.push(step.value)
}
} finally {
abort.abort()
await iterator.next().catch(() => undefined)
}
return frames
}
describe('ActivityController', () => {
it('closes live roster followers when the owning fiber unwinds', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalActivityRegistry)
const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 })
const abort = new AbortController()
const iterator = controller.control(abort.signal)[Symbol.asyncIterator]()
expect((await iterator.next()).value).toMatchObject({ type: 'baseline' })
const pending = iterator.next()
await ctx.fiber.dispose()
// The feed effect closed the follower, ending the generation cleanly.
expect((await pending).done).toBe(true)
})
it('coalesces bursts that land inside the flush window and carries the anchor detail', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalActivityRegistry)
const controller = new ActivityController(ctx, { flushMs: 30, maxFrameBytes: 1024 })
const handle = ctx.activities.open({ kind: 'bash', label: 'burst' })
handle.updateDetail('warming up')
const abort = new AbortController()
const frames: ActivityObserveFrame[] = []
const done = (async () => {
for await (const frame of controller.observe({ activityId: handle.id }, abort.signal)) frames.push(frame)
})()
await new Promise(resolve => setTimeout(resolve, 10))
handle.append('a')
await new Promise(resolve => setTimeout(resolve, 5))
// Lands inside the flush sleep, so the waiter is already dirty at the next wait.
handle.append('b')
await new Promise(resolve => setTimeout(resolve, 80))
handle.end({ status: 'completed' })
await done
expect(frames[0]).toMatchObject({ type: 'opened', detail: 'warming up' })
const text = frames.filter(frame => frame.type === 'output')
.flatMap(frame => frame.chunks.map(chunk => chunk.text)).join('')
expect(text).toBe('ab')
})
it('serves an empty baseline and refuses observation without the registry', async () => {
const ctx = new Context()
const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 })
const abort = new AbortController()
const frames = await collect<ActivityControlFrame>(controller.control(abort.signal), 1, abort)
expect(frames).toEqual([{ type: 'baseline', activities: [] }])
expect(() => controller.observe({ activityId: 'bash-1' as never }, new AbortController().signal))
.toThrow(/activity observation unavailable/)
})
it('streams the roster and one activity end to end over a live registry', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalActivityRegistry)
const controller = new ActivityController(ctx, { flushMs: 5, maxFrameBytes: 1024 })
const handle = ctx.activities.open({ kind: 'bash', label: 'echo' })
handle.append('hi')
handle.end({ status: 'completed', detail: 'exit code: 0' })
const rosterAbort = new AbortController()
const roster = await collect<ActivityControlFrame>(controller.control(rosterAbort.signal), 1, rosterAbort)
expect(roster[0]).toMatchObject({ type: 'baseline' })
expect((roster[0] as Extract<ActivityControlFrame, { type: 'baseline' }>).activities[0]?.label).toBe('echo')
const observeAbort = new AbortController()
const observed = await collect<ActivityObserveFrame>(
controller.observe({ activityId: handle.id }, observeAbort.signal),
3,
observeAbort,
)
expect(observed.map(frame => frame.type)).toEqual(['opened', 'output', 'status'])
})
})
@@ -1,46 +0,0 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"files": [
"src/client/index.ts",
"src/client/model.ts",
"src/client/service.ts",
"src/types.ts"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../gateway/tsconfig.client.json"
},
{
"path": "../../client/connection/tsconfig.client.json"
},
{
"path": "../../client/store"
},
{
"path": "../../activity/activity"
},
{
"path": "../../core/session"
},
{
"path": "../../jobs/jobs"
},
{
"path": "../../llm/llm"
},
{
"path": "../../util/brand"
},
{
"path": "../../typert/protocol"
}
]
}
@@ -1,26 +0,0 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
},
"files": [
"src/index.ts",
"src/invariant.ts",
"src/types.ts",
"src/feed.ts",
"src/observe.ts"
],
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../activity/activity" },
{ "path": "../../core/agent" },
{ "path": "../../core/session" },
{ "path": "../../jobs/jobs" },
{ "path": "../../llm/llm" },
{ "path": "../../runtime-diagnostics/invariants" },
{ "path": "../../typert/protocol" }
]
}
@@ -1,7 +0,0 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.host.json" },
{ "path": "./tsconfig.client.json" }
]
}
@@ -1,7 +0,0 @@
import { clientBundle } from '../../client/tsdown.client.ts'
export default clientBundle(
'@deepseek-ai/dsh-api-activity-controller',
['lib/types/index.js', 'lib/types/invariant.js'],
{ hostPhase: true },
)
-1
View File
@@ -66,7 +66,6 @@
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-api-activity-controller": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
"@deepseek-ai/dsh-api-settings-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
+1 -4
View File
@@ -13,7 +13,6 @@ import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote'
import activityRemote from '@deepseek-ai/dsh-api-activity-controller/remote'
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
@@ -32,8 +31,6 @@ export type {} from '@deepseek-ai/dsh-api-session-controller/remote'
export type * from '@deepseek-ai/dsh-api-session-controller/types'
export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
export type * from '@deepseek-ai/dsh-api-workspace-controller/types'
export type {} from '@deepseek-ai/dsh-api-activity-controller/remote'
export type * from '@deepseek-ai/dsh-api-activity-controller/types'
export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
// The forwarded-event allowlist's selection seat: without it in the consumer's
// compilation face `TypertRemoteEvent` is `never` and every `$on` call fails.
@@ -149,7 +146,7 @@ export async function apply(ctx: Context): Promise<() => Promise<void>> {
for (const contribution of [
agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
subagentsRemote, sessionRemote, workspaceRemote, activityRemote,
subagentsRemote, sessionRemote, workspaceRemote,
]) {
disposers.push(await ctx.remote.$mount(contribution))
}
-1
View File
@@ -26,7 +26,6 @@ import type {} from '@deepseek-ai/dsh-settings/types'
import type {} from '@deepseek-ai/dsh-user-approval'
import type {} from '@deepseek-ai/dsh-user-questions'
export type {} from '@deepseek-ai/dsh-api-session-controller/types'
export type {} from '@deepseek-ai/dsh-api-activity-controller/types'
export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts'
export type { ApiRemoteForwardedEvent } from './types.ts'
@@ -66,9 +66,6 @@
{
"path": "../session-controller/tsconfig.client.json"
},
{
"path": "../activity-controller/tsconfig.client.json"
},
{
"path": "../settings-controller"
},
-3
View File
@@ -60,9 +60,6 @@
{
"path": "../session-controller/tsconfig.host.json"
},
{
"path": "../activity-controller/tsconfig.host.json"
},
{
"path": "../workspace-controller/tsconfig.host.json"
},
@@ -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/api/session-controller/README.md
README.md: d7d1ed3029a8f260af85d65df8595a2726b1e4f1
README.zh.md: a6a4c501d80f7316152c9993b15348cbafed98e0
README.md: dc262b0a1677dd56a723f7b825c6623120b809c8
README.zh.md: 6a2a0c62e33ed263f9844c34bc78dd6762127671
@@ -29,6 +29,8 @@ Each endpoint states its activation policy. List, search, attachment, history pa
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record, and `session.observeJob` streams that record from an absolute byte offset — one `opened` anchor, coalesced `output` frames, then one terminal `status` — with the fenced read resolved from the request's session. Record reads are non-consuming: the model-facing cursor and completion-notice state never observe them. The Client adapter exposes `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views a task-list UI renders; observers share one stream per job and resume from their model cursor across reconnects.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
-----
@@ -40,6 +42,8 @@ The Session object also carries local submission echoes: `session.beginSubmissio
|---|---:|---|
| `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes |
| `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener |
| `observeFlushMs` | `100` | Coalescing window after new record output before a job-observation read, in milliseconds |
| `observeMaxFrameBytes` | `65,536` | Soft byte budget per job-observation output frame; one larger chunk ships whole |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc.
@@ -29,6 +29,8 @@ kind: "package-reference"
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`,`session.observeJob` 从绝对字节偏移流式发送该 record——一个 `opened` 锚帧、聚合的 `output` 帧、最后一个终态 `status` 帧——围栏读取者由请求的 session 解析。record 读取是非消耗的:模型侧游标与完成通知状态永远观察不到它们。Client adapter 提供 `ctx.jobOutput`:按 job 引用计数的观测服务,任务列表 UI 渲染其累积视图;同一 job 的观察者共享一条流,重连时从模型游标续读。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
-----
@@ -40,6 +42,8 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
|---|---:|---|
| `coldBlankProbeMaxBytes` | `1,024` | 可进行空白状态验证的冷 Session 工件最大物理大小;`0` 禁用探测 |
| `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 |
| `observeFlushMs` | `100` | 新 record 输出到一次 job 观测读取之间的聚合窗口,毫秒 |
| `observeMaxFrameBytes` | `65,536` | 每个 job 观测输出帧的软字节预算;更大的单块整块发送 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。
@@ -3,6 +3,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent/types'
import { createSessionControlStream } from './transport.ts'
import { ClientJobOutput, ClientJobOutputModel } from './job-output.ts'
import { ClientSessions } from './sessions/service.ts'
import type { SessionRemotes } from './sessions/remotes.ts'
import type {} from '../remote-events.ts'
@@ -23,6 +24,8 @@ export type {
} from './transport.ts'
export { createScope, scopeOf } from './scope.ts'
export type { AgentContext, AgentScopeHandle } from './scope.ts'
export { ClientJobOutput, ClientJobOutputModel } from './job-output.ts'
export type { IJobOutput, JobOutputSnapshot, JobOutputSource, ObservedJob } from './job-output.ts'
export { SessionCreateError, SessionForkError } from './sessions/service.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export type {
@@ -89,6 +92,14 @@ export const inject = [
export function apply(ctx: Context): void {
const remotes = ctx.remote as unknown as SessionRemotes
const sessions = new ClientSessions(ctx, remotes)
// Resolve the session namespace to a concrete value while this plugin's
// context is current: observation (re)opens run on caller stacks (a React
// event, a carrier retry) whose dynamic context has not declared
// `remote.session`, and a property access there fails the inject check.
new ClientJobOutput(ctx, {
$stream: options => remotes.$stream(options),
session: remotes.session,
}, new ClientJobOutputModel())
ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
ctx.remote.$on('api-session/status', (sessionId, running) => {
@@ -0,0 +1,348 @@
/**
* React-free client job-record observation: per-job accumulated output views
* over the `session.observeJob` stream, reference-counted per job so
* overlapping panels share one stream. The roster itself rides the session
* control stream (`jobsBySession`); this module carries only the output.
* @module @deepseek-ai/dsh-api-session-controller/client/job-output
*/
import { Service, type Context } from '@deepseek-ai/cordis'
import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionJob, SessionObserveJobFrame } from '../types.ts'
import type { SessionRemote } from './transport.ts'
/** Bounded per-job render tail, in UTF-16 code units. */
const RENDER_TAIL_LIMIT = 128 * 1024
/** One observed job's live view state. */
export interface ObservedJob {
readonly jobId: JobId
/** Accumulated output tail, bounded to the render limit. */
readonly text: string
/** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */
readonly gapBefore: boolean
/** Latest status delivered on the observation stream. */
readonly status: SessionJob['status']
readonly detail?: string
/** True while the observation stream is open and the job has not settled. */
readonly streaming: boolean
/** Terminal observation failure, when the stream ended abnormally. */
readonly error?: string
}
/** Immutable client job-output state. */
export interface JobOutputSnapshot {
/** Live observation state keyed by job id. */
readonly observed: Readonly<Record<string, ObservedJob>>
}
/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */
interface ObservedState {
view: ObservedJob
/** Resume offset for the next generation's `from`. */
cursor: number | undefined
}
/**
* Owns per-job observation state. Pure data plus subscriptions — transport
* wiring stays in the client service, UI stays in slot components.
*/
export class ClientJobOutputModel {
private readonly observedStates = new Map<string, ObservedState>()
private readonly listeners = new Set<() => void>()
private snapshotCache: JobOutputSnapshot = { observed: {} }
private snapshotDirty = false
/**
* Read the identity-stable current snapshot.
* @returns the cached snapshot, rebuilt only after a change.
*/
getSnapshot(): JobOutputSnapshot {
if (this.snapshotDirty) {
const observed: Record<string, ObservedJob> = {}
for (const [id, state] of this.observedStates) observed[id] = state.view
this.snapshotCache = { observed }
this.snapshotDirty = false
}
return this.snapshotCache
}
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
/**
* The resume offset for one job's next observation generation.
* @param id - observed job.
* @returns the last accepted `next`, or undefined for a fresh observation.
*/
cursorOf(id: JobId): number | undefined {
return this.observedStates.get(String(id))?.cursor
}
/**
* Install or reset observation state when a generation's anchor arrives.
* @param id - observed job.
* @param frame - the generation's `opened` anchor.
*/
observeOpened(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'opened' }>): void {
const existing = this.observedStates.get(String(id))
// A fresh view anchored past offset zero starts after an evicted head
// (fresh observations anchor at the registry's earliest retained byte), so
// it owes the same gap mark a live observer earned from lossy reads. A
// resume that already accumulated text keeps its recorded gap state.
const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0
const view: ObservedJob = {
jobId: id,
text: existing?.view.text ?? '',
gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: true,
}
this.observedStates.set(String(id), { view, cursor: frame.from })
this.changed()
}
/**
* Append one output frame's chunks to the bounded render tail.
* @param id - observed job.
* @param frame - a coalesced `output` frame.
*/
observeOutput(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'output' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('')
let gapBefore = state.view.gapBefore || frame.lossy === true
|| frame.chunks.some(chunk => chunk.gapBefore === true)
if (text.length > RENDER_TAIL_LIMIT) {
let cut = text.length - RENDER_TAIL_LIMIT
// Never split a surrogate pair at the render bound.
const unit = text.charCodeAt(cut)
if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1
text = text.slice(cut)
gapBefore = true
}
state.view = { ...state.view, text, gapBefore }
state.cursor = frame.next
this.changed()
}
/**
* Record the terminal status delivered at the end of the observation stream.
* @param id - observed job.
* @param frame - the terminal `status` frame.
*/
observeStatus(id: JobId, frame: Extract<SessionObserveJobFrame, { type: 'status' }>): void {
const state = this.observedStates.get(String(id))
/* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
if (state === undefined) return
state.view = {
...state.view,
status: frame.status,
...frame.detail !== undefined ? { detail: frame.detail } : {},
streaming: false,
}
this.changed()
}
/**
* Record a terminal observation failure.
* @param id - observed job.
* @param error - the stream's terminal failure.
*/
observeFailed(id: JobId, error: unknown): void {
const state = this.observedStates.get(String(id))
if (state === undefined) return
state.view = { ...state.view, streaming: false, error: String(error) }
this.changed()
}
/**
* Drop observation state after the last observer stops.
* @param id - the no-longer-observed job.
*/
observeStopped(id: JobId): void {
if (!this.observedStates.delete(String(id))) return
this.changed()
}
private changed(): void {
this.snapshotDirty = true
notifySubscribers(this.listeners, 'job output')
}
}
/** Bare observable source for the client job-output snapshot. */
export interface JobOutputSource {
/** Read the identity-stable current snapshot. */
getSnapshot(): JobOutputSnapshot
/**
* Subscribe to snapshot changes.
* @param listener - invalidation callback.
* @returns unsubscribe function.
*/
subscribe(listener: () => void): () => void
}
/** The job-output client service face. */
export interface IJobOutput {
/** Per-job observation state. */
readonly state: JobOutputSource
/**
* Start observing one job's live record; reference-counted, so two viewers
* of the same job share one stream.
* @param sessionId - owning session used for the fenced read; undefined for an unowned job.
* @param id - job to observe.
* @returns stop function releasing this observer's reference.
*/
observe(sessionId: SessionId | undefined, id: JobId): () => void
}
/** One reference-counted observation stream. */
interface ObservationEntry {
refs: number
stopped: boolean
dispose: () => Promise<void>
}
/** Remote face the observation runner drives. */
type JobObserveRemote = Pick<ClientRemote, '$stream'> & { readonly session: Pick<SessionRemote, 'observeJob'> }
declare module '@deepseek-ai/cordis' {
interface Context {
/** React-free client job-record observation control. */
jobOutput: IJobOutput
}
}
/** Owns the bare job-output snapshot and per-job observation streams. */
export class ClientJobOutput extends Service implements IJobOutput {
readonly state: JobOutputSource
private readonly entries = new Map<string, ObservationEntry>()
/**
* @param ctx - client root Context.
* @param remote - generated session namespace plus the Gateway stream factory.
* @param model - shared client job-output model.
*/
constructor(
ctx: Context,
private readonly remote: JobObserveRemote,
private readonly model: ClientJobOutputModel,
) {
super(ctx, 'jobOutput')
this.state = model
ctx.effect(() => () => {
const open = [...this.entries.values()]
this.entries.clear()
for (const entry of open) {
entry.stopped = true
void entry.dispose()
}
}, 'session-controller.client.observations')
}
observe(sessionId: SessionId | undefined, id: JobId): () => void {
const key = String(id)
const existing = this.entries.get(key)
if (existing !== undefined && !existing.stopped) {
existing.refs += 1
return this.releaser(key, existing)
}
const entry = this.startObservation(sessionId, id)
this.entries.set(key, entry)
return this.releaser(key, entry)
}
/**
* Release closures bind the exact entry they were minted for, never the
* map's current occupant: a later `observe()` on the same id may have
* replaced a stopped entry, and decrementing or disposing through the key
* alone would tear down that newer stream's references.
*/
private releaser(key: string, entry: ObservationEntry): () => void {
let released = false
return () => {
if (released) return
released = true
entry.refs -= 1
if (entry.refs > 0) return
if (this.entries.get(key) === entry) this.entries.delete(key)
entry.stopped = true
void entry.dispose().then(() => {
// Clear the view only while no successor observation holds the key:
// a re-expand inside the dispose round-trip already re-anchored the
// model, and a stale clear would blank its panel for good.
if (this.entries.has(key)) return
// The key is the stringified branded id this releaser was minted for.
this.model.observeStopped(key as JobId)
})
}
}
private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry {
const name = `job observation ${String(id)}`
const stream = this.remote.$stream<SessionObserveJobFrame>({
name,
open: (signal) => {
const from = this.model.cursorOf(id)
return this.remote.session.observeJob(
{
jobId: id,
...sessionId !== undefined ? { sessionId } : {},
...from !== undefined ? { from } : {},
},
signal,
)
},
// A premature end after the anchor is retryable (a Host reload closes the
// generation); resuming from the cursor loses nothing. An end before the
// anchor is terminal.
ended: accepted => accepted
? new RemoteStreamCarrierError(`${name} ended before settlement`)
: new Error(`${name} ended before its anchor`),
})
const entry: ObservationEntry = {
refs: 1,
stopped: false,
dispose: () => stream.dispose(),
}
void (async () => {
try {
for await (const item of stream) {
const frame = item.value
if (frame.type === 'opened') {
this.model.observeOpened(id, frame)
item.accept()
continue
}
if (frame.type === 'output') {
this.model.observeOutput(id, frame)
continue
}
// Terminal status: leave the loop before the generation end is
// classified, then close the stream for good.
this.model.observeStatus(id, frame)
break
}
} catch (error) {
if (!entry.stopped) this.model.observeFailed(id, error)
} finally {
entry.stopped = true
void entry.dispose()
}
})()
return entry
}
}
@@ -212,5 +212,6 @@ function jobView(job: JobSnapshot): SessionJob {
...(job.detail === undefined ? {} : { detail: job.detail }),
startedAt: job.startedAt,
...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }),
...(job.outputTotal === undefined ? {} : { outputTotal: job.outputTotal }),
}
}
@@ -2,6 +2,7 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-jobs'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
@@ -15,6 +16,7 @@ import {
import { SessionCommandController } from './commands.ts'
import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
import { observeJobRecord } from './observe-job.ts'
import { SessionFileReferences } from './file-references.ts'
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
import { buildModelCatalog } from './catalog.ts'
@@ -37,6 +39,8 @@ import type {
SessionKillJobValue,
SessionListRequest,
SessionListValue,
SessionObserveJobFrame,
SessionObserveJobRequest,
SessionOpenWorkspacePathRequest,
SessionOpenWorkspacePathValue,
SessionPage,
@@ -65,12 +69,22 @@ declare module '@deepseek-ai/cordis' {
}
}
/** Default coalescing window between job-observation reads, in milliseconds. */
const DEFAULT_OBSERVE_FLUSH_MS = 100
/** Default soft byte budget per job-observation output frame. */
const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024
/** Session Controller deployment policy. */
export interface Config {
/** Maximum cold Session artifact size eligible for one full projection observation. */
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
/** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
}
/** Host integrations replaceable by direct unit tests. */
@@ -98,6 +112,8 @@ export class SessionController extends TypertRemoteService {
static Config: z<Config> = z.object({
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
nativeOpen: z.boolean(),
observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS),
observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES),
})
private readonly agents: ApiSessionAgentController
@@ -108,6 +124,8 @@ export class SessionController extends TypertRemoteService {
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
private readonly canOpenPath: () => boolean
private readonly promotions = new Set<Promise<void>>()
private readonly observeFlushMs: number
private readonly observeMaxFrameBytes: number
/**
* @param ctx - Host context containing the Session capability assembly.
@@ -132,6 +150,8 @@ export class SessionController extends TypertRemoteService {
this.openPath = internals.openPath ?? openNativePath
this.canOpenPath = internals.canOpenPath
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
this.observeFlushMs = config.observeFlushMs ?? DEFAULT_OBSERVE_FLUSH_MS
this.observeMaxFrameBytes = config.observeMaxFrameBytes ?? DEFAULT_OBSERVE_MAX_FRAME_BYTES
ctx.plugin(SessionFileReferences)
ctx.plugin(SessionSkillCatalog)
@@ -399,6 +419,30 @@ export class SessionController extends TypertRemoteService {
return this.controlState.control(signal)
}
/**
* Stream one job's retained record output from an absolute byte offset,
* then its terminal status once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
* request's session resolves the fenced-read caller; the registry rejects a
* job the session does not own, a job without a record, or an unknown job.
* @param request - target job, owning session, and optional resume offset.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' })
observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable<SessionObserveJobFrame> {
const registry = this.ctx.get('jobs')
if (registry === undefined) {
throw new Error('job observation unavailable: no job registry is loaded (load @deepseek-ai/dsh-jobs-local)')
}
const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId)
return observeJobRecord(registry, request, {
flushMs: this.observeFlushMs,
maxFrameBytes: this.observeMaxFrameBytes,
caller,
}, signal)
}
}
export { buildModelCatalog }
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff. Show more