Merge branch 'master' into copy/ptc-description-20260920

This commit is contained in:
Ziya authored and GitHub committed 2026-09-21 12:38:17 +08:00
commit f206efb628
397 files changed
+12491 -3855

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/archived/feature/2026-08-25-unified-task-list.md
2026-08-25-unified-task-list.md: f2f955e36a78eee8fa8ff8a804293bcc37a44acd
2026-08-25-unified-task-list.zh.md: 5a4c51a6cc3d0f331d2559debb73dc02e9cf12c5
@@ -0,0 +1,49 @@
# Agent Note: One session-header task list — jobs joined with activities in the UI projection only
Status: implemented
Archived: 2026-09-03
English | [中文](2026-08-25-unified-task-list.zh.md)
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).
## Problem
The [activity observation seam](2026-08-24-activity-observation-seam.md) shipped its Web surface as a second session-header button beside the [background-job list](2026-08-08-web-background-job-display.md). A background bash run registers in both planes — `ctx.jobs` for control and `ctx.activities` for observation — so the same work rendered twice: once in "background jobs" with lifecycle and duration, once in "activities" with expandable live output. Two near-identical popovers with different names for overlapping row sets read as a product accident, and the flat activity rows (live and settled interleaved, single-line, no duration) had no visual hierarchy.
## Decision
The service planes stay orthogonal; the join happens once, per row, in the UI projection.
- **`ctx.jobs` and `ctx.activities` remain separate services.** Jobs are the control plane: model-visible ids, a consuming `readOutput` cursor the model drains, `stopping`, `kill`, and the `reported` completion-notice contract. Activities are the transient observation plane: non-consuming absolute offsets, invisible to the model, gone on restart. Merging the services would force one registry to carry both a consuming and a non-consuming cursor and would blur the "model-visible ⟺ logged" line for a plane designed to stay off the session log.
- **`dsh-client-ui-activity` renders the single merged list; `dsh-client-ui-jobs` is deleted** (pre-release, no compatibility shim), and the merged entry takes the job list's slot order. Each `jobsBySession` row is joined with the activity carrying its `correlation.jobId`: the job supplies identity, lifecycle (`stopping` exists only there), duration, and the model-visible `detail`; the activity supplies the expandable output panel. An activity without a job (a workflow run) keeps its own row; an activity whose job is not in the projection is not dropped. Without the activity registry, job rows still render — they just offer no panel, so the merged control degrades to exactly the old job list.
- **User-facing vocabulary is "task" (任务)** — `N tasks running` / `N 个任务进行中` — because every row is running work to the user, while "background job" excludes workflow runs and "activity" names the internal plane, not the user concept. The dictionary namespace and package names stay `activity`: internal names follow the naming ledger, not display copy.
- **Hierarchy**: live rows first (label over a kind-badge-and-status second line, ticking duration), then settled rows compacted to one de-emphasized line; each non-empty section carries its own heading (Running / Finished). Rows without an observable activity render as static rows with no expansion affordance.
Deliberately not joined: subagent delegation rows stay bare job rows (the subagent panel is their surface), and foreground commands stay in their tool cards — neither gains an activity producer.
## Alternatives considered
- **Merge the services** — rejected for the cursor-semantics and model-visibility reasons above.
- **Keep the job list and bolt output onto it** — leaves workflow runs (activities without jobs) homeless, forcing a second list to survive anyway.
## Refinements from browser acceptance
Driving the merged list in the real app produced four presentation decisions, all scoped to the expanded panel and the header band:
- **The header band order is preset → tasks → subagent catalog.** The root-session descendant count moved out of the breadcrumb (`header.lineage`) into the actions band (order 30) and dropped its `/` separator — a root session has no hierarchy to breadcrumb; child sessions keep their `root / child` switcher where the `/` is real. `SubagentCatalogAction` owns the moved seat; the lineage renderer now renders nothing on roots.
- **The panel's copy control copies the command** (`TerminalBlock.copyText`), not the output: a long command ellipsizes in the row, so the control is its only complete source, while output is selectable text below. Supplying `copyText` also keeps the control rendered before any output exists.
- **The panel scrolls instead of folding**: commands and output lines wrap in full (`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`, so the output region never scrolls sideways) and the output region caps at a fixed height (`--dsl-terminal-output-max-height`) with `maxLines: Infinity` disabling the head/tail fold.
- **The panel draws no run-state dot** (`TerminalBlock.runStateDot: false`) — the row above it already carries the same state — and reclaims the dot gutter via `--dsl-terminal-gutter`. The panel's left spine was dropped with it.
All four land as opt-ins on `TerminalBlock` (props or `--dsl-terminal-*` variables), so tool cards keep their shipped fold/ellipsis/dot behavior unchanged.
## Testing
Component suites cover the join (job lifecycle wins, activity supplies the panel, bare-job rows, standalone activities), sections, durations, and the panel options; `pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` runs them. The keyless web e2e pair (`background-job-list`, `live-activity-stream`) replays the merged list end to end against ARIA goldens; `agent-preset-selection` pins the header band order.
## Consequences
- The two web e2e scenarios (`background-job-list`, `live-activity-stream`) now assert one "Tasks" list; the job-list scenario's row gained a live-output panel, which is the join working end to end.
- A human kill control on live rows is still blocked on the jobs `reported` contract question recorded in the job-display note.
- The `job` locale namespace is gone with its package; `activity` owns the merged copy including the duration vocabulary.
@@ -0,0 +1,49 @@
# Agent Note: 会话头部单一任务列表——jobs 与 activity 只在 UI 投影层合并
Status: implemented
Archived: 2026-09-03
[English](2026-08-25-unified-task-list.md) | 中文
Superseded: 本文描述的双名册 join 已随 activity seam 一并移除——job 行现在就是完整名册;见 [jobs 吸收 record](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)。
## 问题
[activity 观察面](2026-08-24-activity-observation-seam.zh.md)的 Web 呈现落地为[后台任务列表](2026-08-08-web-background-job-display.zh.md)旁边的第二个会话头部按钮。一次后台 bash 会同时登记在两个面里——`ctx.jobs` 负责控制、`ctx.activities` 负责观察——于是同一份工作渲染了两次:一次在"后台任务"里带生命周期与时长,一次在"活动"里带可展开的实时输出。两个行集合高度重叠、名字却不同的近似弹层,读起来像产品事故;而活动列表的扁平行(live 与 settled 混排、单行、无时长)也缺乏视觉层次。
## 决策
服务面保持正交;合并只在 UI 投影层逐行发生一次。
- **`ctx.jobs` 与 `ctx.activities` 仍是两个服务。** jobs 是控制面:模型可见的 id、模型消耗式的 `readOutput` 游标、`stopping`、`kill` 与 `reported` 完成通知契约。activity 是瞬态观察面:非消耗式绝对偏移、模型不可见、重启即无。合并服务会迫使一个注册表同时携带消耗式与非消耗式两种游标,并模糊这个刻意不进 session log 的面与"模型可见 ⟺ 已记录"不变量的边界。
- **`dsh-client-ui-activity` 渲染唯一的合并列表;`dsh-client-ui-jobs` 删除**(预发布阶段,不留兼容层),合并入口占用原 job 列表的 slot 顺位。每个 `jobsBySession` 行与携带其 `correlation.jobId` 的 activity 逐行 join:job 提供身份、生命周期(`stopping` 只存在于此)、时长与模型可见 `detail`;activity 提供可展开的输出面板。没有 job 的 activity(workflow 运行)保留自己的行;job 不在投影里的 activity 也不会被丢弃。没有 activity 注册表时 job 行照常渲染——只是没有面板,合并控件退化为与旧 job 列表完全等价。
- **用户可见词汇统一为"任务"(task)**——`N 个任务进行中` / `N tasks running`——因为对用户而言每一行都是在跑的工作,而"后台任务"排除了 workflow 运行、"活动"命名的是内部面而非用户概念。词典命名空间与包名保持 `activity`:内部名遵循命名台账,不跟随展示文案。
- **层次**:进行中的行在前(命令为主行、kind 徽章加状态为副行、时长每秒跳动),已结束的行折为降权单行;每个非空分组带自己的标题(进行中 / 已结束)。没有可观察 activity 的行渲染为无展开交互的静态行。
刻意不合并的:subagent 委托行保持裸 job 行(subagent 面板是它的呈现面),前台命令留在工具卡片里——两者都不接活动生产者。
## 曾考虑的替代方案
- **合并服务层**——因上述游标语义与模型可见性理由否决。
- **保留 job 列表、把输出挂上去**——workflow 运行(无 job 的 activity)无处安放,第二个列表终究得活下来。
## 浏览器验收带来的细化
在真实应用里驱动合并列表得出四个呈现决策,全部限定在展开面板与头部条带:
- **头部条带顺序为 preset → 任务 → subagent 目录。** 根会话的后代数量从面包屑(`header.lineage`)移入操作区(order 30)并去掉 `/` 分隔符——根会话没有可面包屑的层级;子会话保留 `根 / 子` 切换器,那里的 `/` 是真实层级。移位后的席位由 `SubagentCatalogAction` 拥有;谱系 renderer 在根会话上不再渲染任何东西。
- **面板的复制控件复制命令**(`TerminalBlock.copyText`)而非输出:长命令在行内被省略号截断,控件是它唯一的完整来源,而输出本身就是下方可选中的文本。提供 `copyText` 还让控件在任何输出出现之前就保持渲染。
- **面板以滚动取代折叠**:命令与输出行完整换行(`--dsl-terminal-command-whitespace` / `--dsl-terminal-line-whitespace: pre-wrap`,输出区因此永不横向滚动),输出区以固定高度封顶(`--dsl-terminal-output-max-height`),`maxLines: Infinity` 禁用头尾折叠。
- **面板不画运行状态点**(`TerminalBlock.runStateDot: false`)——上方的行已携带同一状态——并通过 `--dsl-terminal-gutter` 收回状态点落区。面板左侧竖线随之移除。
四项全部以 `TerminalBlock` 的可选项落地(prop 或 `--dsl-terminal-*` 变量),工具卡片保持既有的折叠/省略/状态点行为不变。
## 测试
组件套件覆盖 join(job 生命周期优先、activity 提供面板、裸 job 行、独立 activity)、分段、时长与面板选项;`pnpm vitest run packages/client/ui-activity packages/client/ui-primitives packages/client/ui-subagent` 运行它们。无密钥 web e2e 对(`background-job-list`、`live-activity-stream`)按 ARIA golden 端到端回放合并列表;`agent-preset-selection` 钉住头部条带顺序。
## 后果
- 两个 web e2e 场景(`background-job-list`、`live-activity-stream`)现在断言同一个"任务"列表;job 场景的行获得了实时输出面板,这正是 join 端到端生效的证据。
- live 行上的人工 kill 控件仍被 job-display note 记录的 jobs `reported` 契约问题阻塞。
- `job` 词典命名空间随包一起消失;`activity` 拥有合并后的全部文案,包括时长词汇。
+3
View File
@@ -1378,6 +1378,9 @@
"feature/2026-08-25-loaded-turn-chat-navigation.i18n.yaml": "sha256:86bd02d56cf95ac465d0f6e326ccf75432ffb9c3db47373ae6a795195cff9ea7",
"feature/2026-08-25-loaded-turn-chat-navigation.md": "sha256:0ee1cb6da76b93e467d747aa8ba07697ba648cb213ba52202c51ee1e85dff1a8",
"feature/2026-08-25-loaded-turn-chat-navigation.zh.md": "sha256:5d3e567027fc65241856bf9f9cf0d73a20d53214c49bfbc884ab2ddd19503918",
"feature/2026-08-25-unified-task-list.i18n.yaml": "sha256:fec0d4a3ecf59aafd440bc801c686b14d63152c937f51bebe0da61ff615f6f33",
"feature/2026-08-25-unified-task-list.md": "sha256:30bd4e338a3de2f59af98d512c7878928c61846e6700d6012fc45e51c0bf94df",
"feature/2026-08-25-unified-task-list.zh.md": "sha256:88fce0a52b5387ea98c1f68742c03b2f475c5465c680befb32c587066e52cfce",
"feature/2026-08-26-web-trigger-menu-presentation-polish.i18n.yaml": "sha256:69179f3056c6d5fc35119e83dd7a5af30213502dac3c1f2d4fd43eae96ee2ecf",
"feature/2026-08-26-web-trigger-menu-presentation-polish.md": "sha256:c54e3bbe528d6be711541c61e777e6e4ed4fbc1374df97d846b015d3693679f4",
"feature/2026-08-26-web-trigger-menu-presentation-polish.zh.md": "sha256:7db49018eebbba539838d734a80fbb330e2b73fea3b343900591a3605c6c03b5",
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md
2026-06-20-generic-long-running-tool-runtime.md: c386d3b338c94e0e5bbf8e99c2c7a30971ba92b7
2026-06-20-generic-long-running-tool-runtime.zh.md: 002514740a15c5961960d20976fc18f7fd8071ff
2026-06-20-generic-long-running-tool-runtime.md: 5960eb268656d7e316c763631451db1c6d1c6e49
2026-06-20-generic-long-running-tool-runtime.zh.md: ea63ae82962a735b3a96e1803708172a33f83e9d
@@ -27,7 +27,7 @@ The literal types live on the [tasks subsystem page](../../../../docs/subsystems
The process-local provider also owns bounded admission, whose rationale is recorded in the [bounded background job admission decision](../../archived/bug-fix/2026-08-11-bounded-background-job-admission.md). Its positive-safe-integer `maxConcurrentJobsPerOwner` config defaults to `10`; `start()` derives each exact `Agent` object's active count from `running` and `stopping` records, while every unowned task shares one service bucket. Capacity rejection occurs before `run()` and id allocation, and producer `done` settlement is the only event that releases a stopping task's place. The provider does not queue, preempt, or retain a second mutable count.
`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `JobSnapshot`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families.
`outputLimitBytes` is producer-owned presentation policy, not a registry buffer. The registry validates and projects it unchanged into `JobView`; generic control APIs apply the cap to complete model-facing output after adding their own status or notice metadata. Omitting it preserves the existing controller behavior, so the runtime does not impose a hidden default on unrelated producer families.
A model-facing producer exposes that committed id in its canonical success value, normally `{ kind: 'background', jobId }`; Native rendering may keep human-readable prose. A pre-aborted background call fails rather than returning a no-op because no task exists to satisfy the promised handle. Once registration publishes the id, cancellation belongs to the task's own controller and the job runtime: later cancellation of the producing tool call must not kill the published task. `job_kill`, owner disposal, and service teardown request cancellation; foreground execution remains coupled to the call's `exec.signal`.
@@ -62,7 +62,7 @@ For contract-compliant producers, `AgentHandle.dispose()` resolves only after ow
- `read(id, caller?)` for a consuming stream delta or an idempotent final result.
- `kill(id, caller?, reason?)` for cancellation.
- `wait(id, timeoutMs, caller?, signal?)` for bounded terminal waiting.
- `onJobDone(listener)` for effect-scoped observation with exact-owner delivery and listener containment.
- `events.subscribe(filter, listener)` for effect-scoped observation of registration, progress, output, settlement, and removal, filtered by owner or owner scope, with listener containment.
- `attachController(name)` for the task-controller availability fence.
`wait` returns the terminal snapshot when the task settles or the live snapshot when its timeout expires. Aborting a wait cancels only that wait. If settlement has already assigned terminal delivery to the waiter, the terminal snapshot still wins. Waiters unregister synchronously on abort so a same-tick settlement cannot suppress a completion notice on behalf of a reader that receives nothing.
@@ -81,7 +81,7 @@ Stream reads share one task-scoped consuming cursor because the owning model is
The system prompt tells the model to retain job ids, continue independent work instead of busy-polling or duplicating a running task, collect relevant tasks before its final answer, and kill work that no longer matters. Completion delivers a logged message to the exact owner's session. A busy owner is injected; an idle owner is woken, under the bounded policy the [idle-owner wake decision](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md) owns.
The runtime marks a terminal task `reported` when a read or wait delivers it, when a live waiter has claimed delivery at settlement, or when the model explicitly kills it. Reported tasks do not inject redundant completion notices. Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a snapshot carries `outputLimitBytes`, `dsh-tool-jobs` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background job <id>` prefix and `job_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The job controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size.
`dsh-tool-jobs` keeps the delivery ledger: a wait claims the task when it starts (and withdraws if the task outlives the wait) and a `job_kill` claims it when accepted, so a settlement the model already received through a tool result injects no redundant completion notice; teardown settlements and unowned tasks are never announced ([seam consolidation](2026-09-03-jobs-seam-consolidation.md)). Listener failures are logged independently, do not stop later listeners, and are not awaited by waiters or teardown. When a view carries `outputLimitBytes`, `dsh-tool-jobs` preserves UTF-8 boundaries and reuses an existing producer truncation marker rather than duplicating it. Reads reserve status suffixes and retain the output tail; completion notices reserve the stable `background job <id>` prefix and `job_output` instruction before truncating variable kind, label, status, detail, or the truncation marker itself, so the minimum PTY cap still identifies the task to collect. The job controller resolves the caller-visible producer cap in a prepended pre-execute listener before policy can deny or short-circuit dispatch, then applies it through the task definitions' last-mile `finalizeContent` callback so normalized tool errors, outer pipeline failures, and single-text policy results cannot escape the bound; deliberately structured multi-block policy results retain policy ownership of their shape and size.
## Producer opt-in
@@ -105,7 +105,7 @@ Separate bash and subagent output/stop tools duplicate ids, isolation, cleanup,
### An immediate abstract task-runtime backend
The current `JobStart.run()` contract passes in-process callbacks and exact `Agent` objects. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry contract Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics.
The current `JobSpec.run()` contract returns in-process hooks and pushes output through an in-process `JobHandle`, and owners resolve to live `Agent`s in-process. A durable backend changes identity, restart, ownership, and observation semantics, so at introduction time the registry stayed one concrete service rather than freezing the wrong boundary. The [task-registry contract Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md) later separated the contract from the process-local implementation without changing these in-process semantics.
### Consumer-owned authorization or cleanup events
@@ -27,7 +27,7 @@ Status: implemented
进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../../archived/bug-fix/2026-08-11-bounded-background-job-admission.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。
`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。
`outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobView`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。
面向模型的生产方会在规范成功值中暴露已提交的 id,通常为 `{ kind: 'background', jobId }`;Native 渲染仍可保留便于人类阅读的行文。预先被中止的后台调用会失败,而不是返回空操作,因为不存在可履行所承诺句柄的任务。一旦注册过程发布 id,取消就归任务自身的控制器与任务运行时所有:随后取消生产工具调用不得终止已发布的任务。`job_kill`、所有者资源释放和服务拆除会请求取消;前台执行仍与调用的 `exec.signal` 耦合。
@@ -62,7 +62,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访
- `read(id, caller?)`:消费式流增量或幂等的最终结果。
- `kill(id, caller?, reason?)`:取消。
- `wait(id, timeoutMs, caller?, signal?)`:有界的终止等待。
- `onJobDone(listener)`:effect 作用域内的观察,具有精确所有者投递和监听器隔离。
- `events.subscribe(filter, listener)`:effect 作用域内对注册、进度、输出、结算与移除的观察,按所有者或所有者 scope 过滤,具有监听器隔离。
- `attachController(name)`:任务控制器可用性防线。
`wait` 在任务完成时返回终止快照,在等待超时时返回当前快照。中止一次等待只取消该次等待。如果结算已经将终止投递分配给该等待方,终止快照仍然优先。等待方在中止时同步注销,因此同一 tick 内发生结算时,不会代表一个实际未收到任何内容的读取方压制完成通知。
@@ -81,7 +81,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访
系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。
当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job <id>` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
`dsh-tool-jobs` 持有投递台账:等待在开始时认领任务(任务比等待活得更久则撤回),`job_kill` 被接受时也认领,因此模型已经通过工具结果收到的结算不会再注入冗余的完成通知;teardown 结算与无主任务从不播报([seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md))。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当视图携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job <id>` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。
## 生产方显式启用
@@ -105,7 +105,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She
### 立即抽象任务运行时后端
当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。
当前 `JobSpec.run()` 约定返回进程内钩子并经进程内的 `JobHandle` 推送输出,所有者也在进程内解析为存活的 `Agent`。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](../../archived/architecture/2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。
### 由消费方负责授权或清理事件
@@ -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: 508608f7eb694bf131bad9a25e856d32c7079559
2026-09-01-jobs-absorb-activity-record.zh.md: 2c157d951042f1cd6f8a3a66129773bc99cd5b02
@@ -0,0 +1,48 @@
# Agent Note: The job registry absorbs the observation record; the standalone activity seam is removed
Status: implemented
Superseded: the record declaration (`JobStart.record`, `RecordingJob`, `readRecord`, `pumpJobOutput`) and the split wire (`SessionJob.record` on the session control stream, `ctx.jobOutput`) described below were consolidated into one output ring and direct operations on `ctx.jobs` — see [the jobs seam consolidation](2026-09-03-jobs-seam-consolidation.md). The argument for absorbing the standalone activity seam into `ctx.jobs` still holds.
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 — `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }` for a `record: true` start, the plain `RunningJob { id, updateDetail(detail) }` otherwise — 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 splits by role.** `SessionJob.record` (present exactly for record jobs) marks a row observable on the session control stream; `job.follow({sessionId?, jobId, from?})` on the `job` namespace of `api-job-controller` streams anchor/output/status frames with the fenced read resolved from the request's session. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery lives in `api-job-controller` (`observe.ts`, client `ctx.jobOutput`), which resolves its Remote faces in its own apply.
- **`ui-activity` returns to its upstream name `ui-jobs`** and renders one roster: `jobsBySession` rows, expandable exactly when `record` 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.
## Review corrections
Review of the merged design settled five points that the sections above leave implicit:
- **The pump's wait holds constant resources.** `pumpJobOutput` subscribes to the producer's `done` once and keeps one pending timer; settlement clears the timer and wakes the current wait. Racing the same pending promise every poll round retained a reaction per round until the job ended — a day-long job at the default cadence accumulated over a million closures.
- **Client disposal waits for carrier quiescence.** The `ctx.jobOutput` effect disposer is async and awaits every open `RemoteStream.dispose()`, because Cordis awaits async disposers and a fiber that reported unloaded while an old iterator was still closing could overlap the next plugin instance under HMR.
- **The roster flags a record, it does not count bytes.** `SessionJob.record: true` replaced `outputTotal`: `append` commits no roster change (only lifecycle commits fire `onJobsChanged`), so a mirrored count went stale the moment output arrived, and broadcasting the roster per append was rejected as traffic for a number no row needed. The live offsets ride the observation stream's `opened` anchor.
- **The record is a best-effort live preview.** Producers copy stdout and stderr per poll round, so writes inside one window land stdout first; pipe capture cannot recover the true interleaving (only a PTY or `2>&1` can), and the client concatenates chunks regardless of `channel`. `channel` stays on the wire because it is what makes that limit visible and what a distinct stderr rendering would need.
- **The declaration types the face.** `JobStart` is a union discriminated by `record`: `RecordingJobStart.run` receives `RecordingJob`, `PlainJobStart.run` receives `RunningJob` without `append`, and the registry builds the plain face without the method — output the registry has nowhere to keep is unrepresentable instead of warned about and dropped.
- **Web e2e keep-alive is a barrier, not a clock.** The background command holds on a file the test owns in the job's cwd and the scenario kills it explicitly, so no CI stall can settle the job first; each scenario is one test, never a chain of tests sharing a job id.
## 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 `api-job-controller`.
@@ -0,0 +1,48 @@
# Agent Note:job 注册表吸收观测 record;独立的 activity seam 被移除
Status: implemented
Superseded:下文描述的 record 声明(`JobStart.record`、`RecordingJob`、`readRecord`、`pumpJobOutput`)与拆分的线路(会话控制流上的 `SessionJob.record`、`ctx.jobOutput`)已收敛为一个输出环与 `ctx.jobs` 上的直接操作——见 [jobs seam 收敛](2026-09-03-jobs-seam-consolidation.zh.md)。把独立 activity seam 吸收进 `ctx.jobs` 的论证仍然成立。
[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 的生产者面——`record: true` 的 start 收到 `RecordingJob { id, append(text, {channel?, gapBefore?}), updateDetail(detail) }`,其余收到普通的 `RunningJob { id, 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 按角色拆分。** `SessionJob.record`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.follow({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制位于 `api-job-controller`(`observe.ts`,客户端 `ctx.jobOutput`),它在自己的 apply 里解析所依赖的 Remote 面。
- **`ui-activity` 改回上游名字 `ui-jobs`**,渲染单一名册:`jobsBySession` 行,恰在 `record` 存在时可展开。双名册 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 是进程本地观测状态,从不是会话事件,「模型可见 ⟺ 已记录」不受影响。
## 评审修正
对已合并设计的评审确定了上文各节未言明的五点:
- **泵的等待资源恒定。** `pumpJobOutput` 对生产者的 `done` 只订阅一次,同一时刻只保留一个待触发的 timer;结算清掉 timer 并唤醒当前等待。此前每个轮询轮次都对同一个 pending promise 赛跑,每轮留下一个 reaction 直到任务结束——默认节奏下跑一天的任务会累积上百万个闭包。
- **客户端卸载等待载体静止。** `ctx.jobOutput` 的 effect disposer 是异步的,await 每条打开的 `RemoteStream.dispose()`:Cordis 会 await 异步 disposer,而一个在旧 iterator 仍在关闭时就宣告卸载完成的 fiber,在 HMR 下可能与下一个插件实例重叠。
- **名册标记 record,不计字节。** `SessionJob.record: true` 取代了 `outputTotal`:`append` 不提交名册变更(只有生命周期提交触发 `onJobsChanged`),镜像的计数在输出到达的瞬间就陈旧了;而为一个没有任何行需要的数字按 append 广播整份名册被否决。实时偏移随观测流的 `opened` 锚帧到达。
- **record 是尽力而为的实时预览。** 生产者按轮询轮次复制 stdout 与 stderr,同一窗口内的写入先 stdout 落地;pipe 捕获无法还原真实交错(只有 PTY 或 `2>&1` 可以),客户端拼接 chunk 时也不区分 `channel`。`channel` 留在 wire 上,因为它正是让这一限制可见、也是将来区分渲染 stderr 所需要的东西。
- **声明决定生产者面的类型。** `JobStart` 是按 `record` 判别的联合类型:`RecordingJobStart.run` 收到 `RecordingJob`,`PlainJobStart.run` 收到没有 `append` 的 `RunningJob`,注册表给普通 job 构造的面根本没有这个方法——注册表无处存放的输出成为不可表达,而不是警告后丢弃。
- **Web e2e 的保活是 barrier 而非时钟。** 后台命令守在测试拥有的、位于 job cwd 的文件上,由场景显式 kill,因此 CI 卡顿不可能先把任务结算掉;每个场景是一个测试,绝不是共享 job id 的测试链。
## 后果
每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。
@@ -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-03-jobs-seam-consolidation.md
2026-09-03-jobs-seam-consolidation.md: 2742ff1a80ec4cc4d8e5948f63d306abb9753733
2026-09-03-jobs-seam-consolidation.zh.md: 1fa61919a27362ce42419b57e0960e9be86ea07e
@@ -0,0 +1,35 @@
# Agent Note: One output ring, one projection, one event stream for background jobs
Status: implemented
English | [中文](2026-09-03-jobs-seam-consolidation.zh.md)
## Problem
The job seam had grown by accretion. Producers declared their output twice — a consuming `readOutput` hook the model read and an optional `record: true` ring observers read — and the registry kept two vocabularies for one job (`JobSnapshot` for the model, `SessionJob` on the session control stream, `JobWireChunk` on the observation wire). Three listener families (`onJobDone`, `onJobsChanged`, `onOutput`) delivered overlapping facts with different owner filters, and the registry itself tracked `reported`, a bit that only the model-facing tool could interpret. Review of the merged design called the interface layer the weakest part: a reader could not tell which of `read`, `readRecord`, `JobStart`, `RecordingJob`, and `updateDetail` was the contract and which was an accident of history.
## Decision
- **One ring per job.** `JobSpec` replaces `JobStart`: a spec names optional pull `output` sources (`JobOutputSource`, the subprocess `readFrom` family) that the registry pumps at its own cadence (`pumpPollMs` on `dsh-jobs-local`), and every producer receives a `JobHandle` whose `append` pushes chunks into the same ring. The model's consuming read and every observer's absolute-offset read serve the same bytes; `readOutput` and the `record` discriminator are gone. Shell producers hand the registry their `observed` offset readers and fold sandbox facts into the terminal `detail`; the terminal producer adapts its consuming send reader through a byte-counting source; the subagent returns its answer as `JobOutcome.result`, which the first read after settlement carries once.
- **Two cursors, one storage.** `JobRegistry.read(id)` is the stateful model cursor; `JobRegistry.readAt(id, from)` is the stateless observer read. Model reads exclude `log` chunks (producer narration for observers) and render stdout, then one `[stderr]` section, exactly as the shell tools always did.
- **`updateProgress` replaces `updateDetail`.** `JobView.progress` is the live line and is cleared at settlement; `JobView.detail` is the terminal reason, with the model's `job_kill` reason merged in.
- **One projection.** `JobView` (client-safe leaf `@deepseek-ai/dsh-jobs/view`) is what the model tools, the browser roster, and the observation frames consume; `owner` is the session id, never the `Agent`.
- **Direct caller-scoped operations.** `JobRegistry.list`, `get`, `read`, `readAt`, `kill`, and `wait` accept the caller session on each call. Omitting the caller permits only unowned jobs. The output ring and browser streams do not require a separate caller-bound operation object.
- **One event stream.** `events.subscribe(filter, listener)` with `{ owner }`, `{ owners: 'scope' }`, or `{ owners: 'all' }` replaces the three listener families. `settled` names its cause (`producer`, `kill`, `teardown`); `output` carries only the id and the new total.
- **The notice ledger moves to `dsh-tool-jobs`.** The registry no longer tracks `reported`. The tool claims a job when a wait starts (and withdraws on timeout or abort) and when `job_kill` is accepted; the settlement event drops the entry, teardown settlements and unowned jobs are skipped, and the destination is the agent registered for the owner session at settlement.
- **The roster leaves the session control stream.** `dsh-api-job-controller` owns `job.list({ sessionId })` — whole-set frames after each coalesced burst of lifecycle commits, never per append — beside `job.follow`, and its client installs `ctx.jobs` (`watchRows`, `observe`, one snapshot). `dsh-api-session-controller` carries queues and projections only; `ui-jobs` watches its session's roster while mounted, and a row is expandable while live or once it left retained output behind.
`attachController` and the owner-relative `servesOwner` gate stay as they were; whether a producer may offer `run_in_background` without a mounted controller is deferred, since the gap predates this change.
## Alternatives considered
- **Keep three listeners with a shared owner filter.** It preserves the accreted names but not the property reviewers asked for — one place to learn what the registry announces — and `onJobDone`'s exact-owner delivery is what forced the registry to keep an `Agent` in every projection.
- **Keep `reported` in the registry.** Only the tool knows which deliveries reach the model (a wait's tool result, a kill's acknowledgement); the registry could only approximate it, and the approximation was the source of the double-notice and missed-notice bugs the review listed.
- **Keep the roster on the session control stream.** It saved one Remote stream but coupled the session controller to the job registry and mirrored a `record` flag the ring's byte count now answers directly (`output.total`).
- **Serve `log` chunks to the model too.** Producer narration is for observers; the model already receives the same facts through `progress` and the terminal `detail`.
## Consequences
The model-visible text is unchanged: status lines, tool descriptions and schemas, and notice wording are byte-identical, and the only new model-visible fact is the kill reason a `killed` status line now carries. A producer that needs to stream output picks pull sources or `append`; it never formats a consuming delta. The Web client renders every live job as expandable, so a job that never writes shows an empty running panel instead of a static row. `JobKindMap` merges are host-side, so a browser program types `JobView.kind` as the kinds it knows and treats the rest as opaque strings, which is all the roster does with it.
Review follow-ups after the master merge tightened four of these edges. A job's first event is always `registered`: the announcement precedes the pump, whose first drain runs synchronously and may announce output. A settled ring keeps every byte the model cursor has not consumed until the first terminal model read, so a job that finishes before its first `job_output` loses nothing the live cap retained. `dsh-tool-jobs` holds one claim per wait call with a `live` flag that the caller's abort clears synchronously from the abort event, so a settlement announced before the registry's rejection reaches the tool still delivers its notice, and a timed-out wait drops only its own claim while a concurrent wait keeps the settlement covered. The browser records an observation failure that precedes the anchor as an errored view, so the panel shows the notice instead of staying blank. The `@deepseek-ai/dsh-jobs/invariant` companion checks the announced event protocol per job (registered first, one settlement, removal last) and each announced projection against the registry's own read, which is the cross-observation the [companion rule](../simplification/2026-08-28-omit-unneeded-invariant-companions.md) requires; the earlier single-view field checks are gone. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, reported by every pump read and withdrawn once a read stops naming it), not per-chunk metadata, and the model's read renders the shell tools' dropped-output notice for a producer-side gap as well as for ring eviction, naming those files even after the gap chunk itself was evicted — the `before this read` variant this change had introduced is gone. An observation generation that sees its job removed mid-stream (the owner's teardown) closes with the removed job's terminal projection instead of a failed read. A rejected background spawn produces no process output, so the shell providers now serve the `spawn failed: …` note as the whole observed stderr stream (the consuming read still folds it in once), and `ShellProcess.observed` with both readers is required: the model's first `job_output` after such a failure reads exactly what master's consuming read rendered.
@@ -0,0 +1,35 @@
# Agent Note:后台 job 的一个输出环、一个投影、一条事件流
Status: implemented
[English](2026-09-03-jobs-seam-consolidation.md) | 中文
## 问题
job seam 是逐步堆出来的。生产者要把输出声明两次——模型读取的消耗式 `readOutput` 钩子,以及观察者读取的可选 `record: true` 环——注册表也为同一个 job 保留了两套词汇(模型侧的 `JobSnapshot`、会话控制流上的 `SessionJob`、观测线路上的 `JobWireChunk`)。三个监听器家族(`onJobDone`、`onJobsChanged`、`onOutput`)用不同的拥有者过滤投递彼此重叠的事实,注册表自己还跟踪着只有模型侧工具才解释得了的 `reported` 位。合并后设计的评审把接口层称作最薄弱的部分:读者分不清 `read`、`readRecord`、`JobStart`、`RecordingJob` 与 `updateDetail` 里哪些是约定、哪些是历史遗留。
## 决策
- **每个 job 一个环。** `JobSpec` 取代 `JobStart`:spec 声明可选的拉取式 `output` 源(`JobOutputSource`,子进程的 `readFrom` 家族),由注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送;每个生产者都收到一个 `JobHandle`,其 `append` 把块推进同一个环。模型的消耗式读取与每个观察者的绝对偏移读取服务同一批字节;`readOutput` 与 `record` 判别字段被移除。shell 生产者把它们的 `observed` 偏移读取器交给注册表,并把沙箱事实折进终态 `detail`;terminal 生产者通过一个计字节的源适配其消耗式发送读取器;subagent 把回答作为 `JobOutcome.result` 返回,结算后的第一次读取携带它一次。
- **两个游标,一份存储。** `JobRegistry.read(id)` 是有状态的模型游标;`JobRegistry.readAt(id, from)` 是无状态的观察者读取。模型读取排除 `log` 块(给观察者的生产者叙述),并渲染 stdout,再接一段 `[stderr]`,与 shell 工具一贯的做法完全一致。
- **`updateProgress` 取代 `updateDetail`。** `JobView.progress` 是实时行,结算时清除;`JobView.detail` 是终态原因,合并了模型 `job_kill` 给出的原因。
- **一个投影。** `JobView`(客户端安全叶子 `@deepseek-ai/dsh-jobs/view`)是模型工具、浏览器名册与观测帧共同消费的形状;`owner` 是会话 id,从不是 `Agent`。
- **直接传入调用方的操作。** `JobRegistry.list`、`get`、`read`、`readAt`、`kill` 与 `wait` 在每次调用时接收调用方会话。省略调用方时仅允许访问无主 job。输出环与浏览器流不需要独立的调用方操作对象。
- **一条事件流。** 带 `{ owner }`、`{ owners: 'scope' }` 或 `{ owners: 'all' }` 过滤的 `events.subscribe(filter, listener)` 取代三个监听器家族。`settled` 标出原因(`producer`、`kill`、`teardown`);`output` 只携带 id 与新的 total。
- **播报台账移到 `dsh-tool-jobs`。** 注册表不再跟踪 `reported`。工具在等待开始时认领 job(超时或中止则撤回),在 `job_kill` 被接受时也认领;结算事件删除条目,teardown 结算与无主 job 被跳过,投递目标是结算时登记在拥有者会话下的 agent。
- **名册离开会话控制流。** `dsh-api-job-controller` 在 `job.follow` 旁拥有 `job.list({ sessionId })`——每轮聚合过的生命周期提交之后发一次整集帧,从不按追加发——其客户端安装 `ctx.jobs`(`watchRows`、`observe`、一份快照)。`dsh-api-session-controller` 只承载队列与投影;`ui-jobs` 在挂载期间关注其会话的名册,行在 job 进行中或留有保留输出时可展开。
`attachController` 与相对拥有者的 `servesOwner` 门保持原样;没有挂载控制器的生产者是否可以提供 `run_in_background`,因为这个缺口早于本次变更,暂不处理。
## 考虑过的替代方案
- **保留三个监听器并共享一个拥有者过滤。** 它保住了堆出来的名字,却保不住评审要的性质——一个地方就能学会注册表宣布什么——而且 `onJobDone` 的精确拥有者投递正是迫使注册表在每个投影里保留 `Agent` 的原因。
- **把 `reported`留在注册表。** 只有工具知道哪些投递到达了模型(等待的工具结果、kill 的确认);注册表只能近似,而这个近似正是评审列出的重复播报与漏播报 bug 的来源。
- **把名册留在会话控制流上。** 省了一条 Remote 流,却把会话控制器耦合到 job 注册表,还镜像了一个环的字节数如今直接回答的 `record` 标志(`output.total`)。
- **也把 `log` 块交给模型。** 生产者叙述是给观察者的;模型已经通过 `progress` 与终态 `detail` 收到同样的事实。
## 后果
模型可见文本保持不变:状态行、工具描述与 schema、播报措辞逐字节相同,唯一新增的模型可见事实是 `killed` 状态行现在携带的 kill 原因。需要流式输出的生产者选择拉取源或 `append`,从不格式化消耗式增量。Web 客户端把每个进行中的 job 渲染为可展开,因此从不写输出的 job 显示为空的运行中面板而非静态行。`JobKindMap` 的合并发生在宿主侧,浏览器程序把 `JobView.kind` 视为它认识的 kind 集合,其余当作不透明字符串——名册对它也只做这些。
合并 master 之后的评审跟进收紧了其中四条边。job 的第一个事件永远是 `registered`:通告先于泵,而泵的首轮 drain 同步执行且可能通告输出。已结算的环在模型的首次终态读取之前保留模型游标尚未消费的全部字节,因此在首次 `job_output` 之前结束的 job 不会丢失运行期上限保留的任何内容。`dsh-tool-jobs` 为每次等待调用保存一条带 `live` 标记的认领,调用方 abort 时在 abort 事件里同步清掉该标记,因此注册表的拒绝尚未传回工具之前宣布的结算仍会投递通知;超时的等待只丢弃自己的认领,并发的另一个等待仍覆盖该结算。浏览器把先于锚点发生的观察失败记录为带错误的视图,面板显示提示而不是留白。`@deepseek-ai/dsh-jobs/invariant` 伴生插件校验每个 job 的事件协议(先 `registered`、恰一次结算、最后 `removed`)以及每个通告的投影与注册表自身读取的一致性,这正是[伴生插件规则](../simplification/2026-08-28-omit-unneeded-invariant-companions.zh.md)要求的交叉观察;先前只针对单个视图的字段校验已删除。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会上报,读取不再点名时即撤回),不是逐块元数据;模型读取对生产者侧缺口与环淘汰都渲染 shell 工具的丢失输出提示并列出这些文件,即使缺口块本身已被淘汰——本次变更曾引入的 `before this read` 变体已删除。观察生成过程若在流中看到其 job 被移除(拥有者 teardown),以被移除 job 的终态投影收尾,而不是一次失败的读取。被拒绝的后台 spawn 不产生任何进程输出,因此 shell provider 现在把 `spawn failed: …` 提示作为 observed stderr 的整条流提供(消耗式读取仍只折入一次),且 `ShellProcess.observed` 及其两个读取器改为必填:这类失败之后模型的首次 `job_output` 读到的正是 master 的消耗式读取所渲染的内容。
@@ -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: 4b8396c1c0504ddf8494a03c22b9af094c4eed17
2026-08-08-web-background-job-display.zh.md: 03a43b87c47d0ef4d9c08d32facd91ceb76dda38
2026-08-08-web-background-job-display.md: 049785e9cd5dea2d92d9fc5a14dfeddac9b64469
2026-08-08-web-background-job-display.zh.md: 1dd7ee9215fa034a0a7abbe75bd5f6eb6907e9ee
@@ -2,6 +2,10 @@
Status: implemented
Superseded: the roster this note put on the session control stream (`jobsBySession`, `onJobsChanged`) now streams from the job controller's `job.list` into `ctx.jobs` — see [the jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The display decisions below (one roster, sections, durations, no kill control) still hold.
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
@@ -64,7 +68,7 @@ The listener is owner-granular rather than task-granular. The only consumer push
`onJobDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-jobs` couples to `reported`; `onJobsChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onJobDone`, and each registration is an effect on the calling fiber.
Service disposal deliberately announces nothing. Every `onJobsChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set.
Service disposal announces each removal: after the registry has cancelled and awaited its jobs, it drops every record and emits one `removed` event per job, so a subscriber on a longer-lived fiber sees the roster empty out instead of keeping a stale set (a subscriber on the registry's own fiber is already gone by then).
### The Session Controller carrier
@@ -87,7 +91,7 @@ Two replacement points keep it honest. Each control-stream generation clears the
### The header action
[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.md) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible.
`@deepseek-ai/dsh-client-ui-jobs` registers one entry in `conversation.session.header.actions`, ordered between the preset label and the subagent catalog (`order: 20` against the catalog's 30). Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible.
A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks.
@@ -117,7 +121,7 @@ A running one-shot background subagent therefore appears both there and in the s
The [web e2e scenario](../../../../apps/web/tests/background-job-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.jobs`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer.
Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) pins the complete baseline, three change pushes, dropped internal fields, unowned fan-out, no-resume guarantee, registry-absent composition, and the prohibition on consuming model output; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior.
Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) pins the roster stream that replaced the control-stream fan-out: the complete visible set on open, a refresh after each lifecycle commit and none per append, owner removal, and a clean abort; and the client suites pin baseline replacement, the last-wins fold, the absent-key representation, removal cleanup, and the component's ordering, duration, and dismissal behavior.
## Consequences
@@ -2,6 +2,10 @@
Status: implemented
Superseded:本文放在会话控制流上的名册(`jobsBySession`、`onJobsChanged`)现在从 job 控制器的 `job.list` 流入 `ctx.jobs`——见 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)。下文的展示决策(单一名册、分组、时长、没有 kill 控件)仍然成立。
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) | 中文
## 问题
@@ -64,7 +68,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
`onJobDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-jobs` 把这套语义与 `reported` 绑在一起;`onJobsChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onJobDone` 一致,每次注册都是调用方 fiber 上的 effect。
服务销毁刻意什么都不通告。每个 `onJobsChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。
服务销毁逐条通告移除:注册表取消并等完自己的 job 之后,丢弃每条记录并对每个 job 发出一个 `removed` 事件,因此注册在更长命 fiber 上的订阅者看到名册逐条清空而不是留着陈旧集合(注册在注册表自身 fiber 上的订阅者此时早已消失)。
### Session Controller 载体
@@ -87,7 +91,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
### header 入口
[`@deepseek-ai/dsh-client-ui-jobs`](../../../../packages/client/ui-jobs/README.zh.md) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。
`@deepseek-ai/dsh-client-ui-jobs` 在 `conversation.session.header.actions` 注册一个条目,排在 preset 标签与 subagent 目录之间(`order: 20`,目录为 30)。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。
因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。
@@ -117,7 +121,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
[web e2e 场景](../../../../apps/web/tests/background-job-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.jobs`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。
在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`control-jobs`](../../../../packages/api/session-controller/tests/control-jobs.host.spec.ts) 钉住完整 baseline、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证、没有注册表的组合,以及不得消费模型输出;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。
在它之下,[`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`rows`](../../../../packages/api/job-controller/tests/rows.host.spec.ts) 钉住取代控制流扇出的名册流:打开时的完整可见集、每次生命周期提交后刷新一次且追加不刷新、拥有者移除,以及干净的中止;客户端各套件钉住 baseline 替换、last-wins 折叠、缺失键表示、移除清理,以及组件的排序、时长与关闭行为。
## 影响
@@ -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-11-background-job-completion-wakes-an-idle-owner.md
2026-08-11-background-job-completion-wakes-an-idle-owner.md: cb724d95f85900b3c55fdebfdc335e9da6439d29
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: 1107fa2c8fdd9d3e4e45031c54fb87f5aac8ea18
2026-08-11-background-job-completion-wakes-an-idle-owner.md: b8a10a2150ead02036cb97506a25c3a51202adcf
2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md: adc0770cd00adeb3b13c95dcc386464493dcd943
@@ -2,6 +2,8 @@
Status: implemented
Update: the `reported` bit and `onJobDone` this note relies on left the registry with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md): `dsh-tool-jobs` now keeps the delivery ledger (a wait or an accepted `job_kill` claims the task) and skips teardown settlements by their `settled` cause, so the wake-or-inject decision below stands while the mechanism it names is the tool's ledger, not a registry flag.
English | [中文](2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)
## Problem
@@ -2,6 +2,8 @@
Status: implemented
Update:本文依赖的 `reported` 位与 `onJobDone` 已随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)离开注册表:`dsh-tool-jobs` 现在持有投递台账(等待或被接受的 `job_kill` 认领任务),并按 `settled` 事件的 cause 跳过 teardown 结算,因此下文唤醒或注入的决策仍然成立,只是它提到的机制是工具的台账而非注册表标志。
[English](2026-08-11-background-job-completion-wakes-an-idle-owner.md) | 中文
## 问题
@@ -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/feature/2026-08-24-activity-observation-seam.md
2026-08-24-activity-observation-seam.md: 21fe08fd21ad95eec7a83f6f29b9b94514358bb9
2026-08-24-activity-observation-seam.zh.md: ffb6766b3e5d226d324ea2894a6e3f758cf1932a
@@ -0,0 +1,53 @@
# Agent Note: The activity observation seam (`ctx.activities`) and live output streaming to the Web client
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
Every incremental reader in the shell, terminal, and jobs layers is single-consumer and consuming: `ShellProcess.readOutput()`, `TerminalSendOperation.readOutput()`, and `JobRegistry.read()` all destroy the delta they return, because the owning model is the intended reader. The [web background-job display](2026-08-08-web-background-job-display.md) shipped the roster on those terms — its carrier is test-pinned to never call `ctx.jobs.read()`, since a browser read would silently steal bytes the model's `job_output` would never see — and explicitly deferred the output phase to "a separate non-consuming observation API". So a human watching the Web client saw that a background build was running but never a line of its output, and the `workflow/log` / `workflow/phase` events scripts are told to narrate with had no consumer at all.
The substrate below bash already had the right shape: `SubprocessOutputReader.readFrom(fromByte)` is offset-based and explicitly non-consuming, and `bash-local` narrows it to a single cursor pair at the shell seam.
## Decision
A new capability seam, `packages/activity/`, is the non-consuming observation plane. An **activity** is one observable unit of long-running work: an append-only bounded output stream plus live status. The seam is pure observation — it starts nothing, cancels nothing, and is invisible to the model. Model-visible facts stay exactly where they were (tool results, `ctx.jobs` cursors), so the "model-visible ⟺ logged" invariant is untouched and a composition without the seam loses only live observation.
- **`@deepseek-ai/dsh-activity` (Service Definition)** — abstract `ActivityRegistry` on `ctx.activities`: `open(spec) → ActivityHandle { append, updateDetail, end }`, fenced `get`/`list`, non-consuming `read(id, from)` over absolute UTF-8 byte offsets, owner-relative `onActivitiesChanged` (roster) and `onOutput` (advancement signal, id only). `ActivityCorrelation { callId?, jobId? }` links a row to its tool call and job. `pumpActivityOutput` is the shared pull-substrate pump.
- **`@deepseek-ai/dsh-activity-local` (Service Provider)** — in-memory chunk ring per activity. Offsets stay absolute across eviction (`outputEarliest` marks the oldest retained byte; a read below it is `lossy`, never an error); a single over-cap chunk keeps its UTF-8-safe tail with `gapBefore`. Config `retainBytes` (256 KiB) live, trimmed to `settledRetainBytes` (16 KiB) at settlement. Records outlive producer fibers; owner disposal force-ends and removes; `end` is first-wins and post-end writes log-and-drop so a producer's trailing flush cannot break its own teardown.
- **`@deepseek-ai/dsh-api-activity-controller`** — the wire, in the workspace-controller shape: `activity.control` streams one roster baseline then whole-bucket replacement frames per owner session (the jobs-frame self-healing semantics), and `activity.observe({ activityId, from? })` streams one `opened` anchor, coalesced `output` frames (`flushMs` window, `maxFrameBytes` soft budget), then the terminal `status` on the same stream before closing — so settlement can never race a still-open output channel. Reconnects resume from the last frame's `next`. The client half installs `ctx.activityFeed` (roster mirror plus reference-counted observation streams with a bounded render tail).
- **`@deepseek-ai/dsh-client-ui-activity`** — the session-header task list ([unified with the job rows](../../archived/feature/2026-08-25-unified-task-list.md)); expanding a row opens its observation stream into a `TerminalBlock` panel, collapsing closes it, so output only flows while someone is watching. `TerminalBlock` itself gained live rendering: a running block with supplied `output` shows the text under the running state instead of the historical prompt-only frame.
Producers mirror best-effort behind `ctx.get('activities')`, never a declared inject, and every observation failure is logged and swallowed — the seam is strictly optional and can never break the work it observes:
- `ShellProcess` gained optional `observed` — the substrate's non-consuming offset readers re-exposed (bash-local and pwsh-local return `handle.collected`; the e2b subprocess provider satisfies the same contract). `dsh-tool-bash` / `dsh-tool-pwsh` open a correlated activity after `jobs.start()` commits and pump `observed` at `activityPollMs` (default 150 ms).
- `dsh-tool-workflow` mirrors each recorded top-level run as a push-mode `workflow` activity, giving the previously unconsumed `workflow/phase` / `workflow/log` events their consumer.
### Why not durable session events, the control stream, or the jobs seam
Peer products converge on the same split (Codex marks every exec-output delta "Transient, non-durable" in its rollout policy; Claude Code persists a file path, never deltas; Kimi's envelope has a first-class `volatile: true`; OpenCode's V2 corrected V1's per-chunk durable part updates to "stream fragments are live-only"). In this repository the durable route is also concretely bad: chunk-run packing is hardcoded to the three `assistant/chunk` shapes so a new output event gets zero compression, each event is one uncoalesced WebSocket frame, and reconnect gap-repair re-reads the window — and the job-display note already rejected it ("spill exists precisely so oversized tool output stays out of the log"). The session control stream's whole-snapshot frames are self-healing for rosters but quadratic for streams. Extending the jobs seam would cover only jobs — not workflow narration, not a future foreground stream — and would re-litigate the settled "producers own their buffers" decision; the activity plane instead recovers the substrate's existing offsets.
### Two cursor regimes, kept separate
The model keeps its consuming cursors (`job_output` semantics unchanged, zero prompt or tool changes anywhere in this work); observers get absolute offsets any number of readers can hold. This mirrors the strongest peer pattern (Codex: destructive drain for the model, seq-cursor reads for infrastructure) and is what makes the wire path provably non-stealing.
## Alternatives considered
**Durable `activity/output` session events with a conversation node.** Replay for free, but the log-growth math above, plus retention pressure on every follower, killed it; the transcript's durable story remains the producers' tool results.
**Extending `SessionControlFrame` with output.** One fewer stream, but whole-snapshot semantics resend the accumulated buffer per chunk — O(n²) on exactly the data that grows.
**A signal frame plus RPC pull.** The subagent-catalog shape; rejected for the split-authority apparatus the job-display note documents, and it fails worst at settlement (running row, dead stream).
**Observation inside the jobs seam.** One integration point, but only for jobs; foreground runs, terminals, and workflow narration would each need another mechanism, and the registry would grow a second buffer beside the producers' own.
## Testing
Unit suites pin the registry (offsets, eviction, UTF-8-safe tail trims, lossy reads, first-wins settlement, owner cleanup, scoped delivery, listener containment, HMR disposal, Loader composition with row config), the pump (cadence, per-source cursors, lossy `gapBefore`, settlement drain, timer hygiene), the controller (baseline/bucket frames, anchor/output/status ordering, resume, frame splitting, fenced owned reads, abort), the client model and reference-counted streams, and both producer taps (real subprocess output for bash including the never-steals-the-model's-cursor assertion; scripted readers for pwsh; event-driven mirror for workflow, including the throwing-registry containment the optionality promise requires). The keyless web e2e drives a real `run_in_background` bash through the full composition and pins ARIA goldens for the streaming panel while the command still runs and for the settled panel after a registry kill.
## Consequences
The Web client now shows live output for background bash/pwsh and workflow narration with zero model-facing change, and any plugin can join with three calls (`open`/`append`/`end`) against an optional service. Bought at the cost of one more seam family and a per-producer tap; the initial two-header-list duplication was folded into [one task list](../../archived/feature/2026-08-25-unified-task-list.md). Deferred, recorded on the package READMEs: foreground streaming (needs a shell-seam run/observe variant), terminal `pty-send` observation (needs a non-consuming face on terminal buffers), a human kill control (blocked on the jobs `reported` contract), SDK/ACP consumers, and durable replay after restart.
@@ -0,0 +1,53 @@
# Agent Note:activity 观察 seam(`ctx.activities`)与 Web 客户端实时输出流
状态: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) | 中文
## 问题
shell、terminal 与 jobs 各层的增量读取器全都是单读者且消费型的:`ShellProcess.readOutput()`、`TerminalSendOperation.readOutput()` 与 `JobRegistry.read()` 返回即销毁增量,因为预期读者就是持有模型。[Web 后台任务显示](2026-08-08-web-background-job-display.zh.md)在这一前提下交付了 roster——其载体被测试钉死绝不调用 `ctx.jobs.read()`,否则浏览器读取会无声偷走模型 `job_output` 永远看不到的字节——并把输出阶段明确推迟给"一个独立的非消费观察 API"。于是人在 Web 客户端只能看到后台构建"在跑",看不到一行输出;脚本被告知用来叙述进度的 `workflow/log` / `workflow/phase` 事件更是全无消费者。
bash 之下的底座其实早有正确形态:`SubprocessOutputReader.readFrom(fromByte)` 是按 offset、明确非消费的,`bash-local` 在 shell seam 把它窄化成了单游标对。
## 决策
新建能力 seam `packages/activity/`,作为非消费观察面。**activity** 是一个可观察的长时工作单元:一条 append-only 的有界输出流加实时状态。该 seam 是纯观察——不启动、不取消任何工作,对模型不可见。模型可见事实原地不动(工具结果、`ctx.jobs` 游标),"模型可见 ⟺ 已记录"不变量不受影响;不装该 seam 的组合只失去实时观察。
- **`@deepseek-ai/dsh-activity`(Service Definition)**——`ctx.activities` 上的抽象 `ActivityRegistry`:`open(spec) → ActivityHandle { append, updateDetail, end }`、有栅栏的 `get`/`list`、按绝对 UTF-8 字节 offset 的非消费 `read(id, from)`、owner 相对投递的 `onActivitiesChanged`(roster)与 `onOutput`(推进信号,只带 id)。`ActivityCorrelation { callId?, jobId? }` 把行关联到工具调用与 job。`pumpActivityOutput` 是 pull 型底座的共享泵。
- **`@deepseek-ai/dsh-activity-local`(Service Provider)**——每个 activity 一个内存 chunk 环。offset 跨淘汰稳定(`outputEarliest` 指明最老保留字节;低于它的读取是 `lossy` 而非错误);单个超上限 chunk 保留 UTF-8 安全尾部并带 `gapBefore`。配置 `retainBytes`(256 KiB)存活,结算时裁到 `settledRetainBytes`(16 KiB)。记录存续期长于生产者 fiber;owner 销毁强制终结并移除;`end` first-wins,end 后写入记日志后丢弃,生产者的收尾 flush 砸不掉自己的 teardown。
- **`@deepseek-ai/dsh-api-activity-controller`**——wire 层,取 workspace-controller 的形态:`activity.control` 流出一份 roster baseline 加按 owner 会话的整桶替换帧(jobs 帧的自愈语义);`activity.observe({ activityId, from? })` 流出一帧 `opened` 锚点、合并的 `output` 帧(`flushMs` 窗口、`maxFrameBytes` 软预算)、再在同一条流上发终态 `status` 后关闭——结算永远不会与仍开着的输出通道竞态。重连以上一帧的 `next` 续传。客户端半区安装 `ctx.activityFeed`(roster 镜像 + 按引用计数的观察流与有界渲染尾巴)。
- **`@deepseek-ai/dsh-client-ui-activity`**——会话头部任务列表([与 job 行合并](../../archived/feature/2026-08-25-unified-task-list.md));展开某行即把其观察流打开进 `TerminalBlock` 面板,收起即关——只有有人在看时输出才流动。`TerminalBlock` 本身获得实时渲染能力:running 且提供了 `output` 的块在 running 状态下显示文本,而非历史上的仅提示行画面。
生产者一律经 `ctx.get('activities')` 尽力镜像,绝不声明 inject,一切观察失败只记日志并吞掉——该 seam 严格可选,永远不能破坏它所观察的工作:
- `ShellProcess` 新增可选 `observed`——底座的非消费 offset 读取器复出(bash-local 与 pwsh-local 返回 `handle.collected`;e2b subprocess provider 满足同一契约)。`dsh-tool-bash` / `dsh-tool-pwsh` 在 `jobs.start()` 提交后打开带关联的 activity,并以 `activityPollMs`(默认 150 ms)泵取 `observed`。
- `dsh-tool-workflow` 把每个被记录的顶层运行镜像为 push 形态的 `workflow` activity,让此前无人消费的 `workflow/phase` / `workflow/log` 事件有了消费者。
### 为何不用持久会话事件、control 流或 jobs seam
同行产品收敛于同一切分(Codex 在 rollout policy 里把所有 exec 输出 delta 标为 "Transient, non-durable";Claude Code 持久化文件路径而非 delta;Kimi 的信封有一等的 `volatile: true`;OpenCode V2 把 V1 逐 chunk 持久化 part 更新纠正为 "stream fragments are live-only")。在本仓库持久路线还有具体的坏处:chunk-run 打包只认三种 `assistant/chunk` 形态,新输出事件零压缩;每事件一条未合并的 WebSocket 帧;重连缺口修复要重读窗口——且 job 显示 note 已否决过它("spill 的存在就是为了让超大工具输出不进日志")。session control 流的整值帧对 roster 自愈、对流则是二次方开销。扩 jobs seam 只覆盖 jobs——workflow 叙述与未来的前台流都覆盖不到——还要重翻已定案的"生产者拥有自己的缓冲";activity 面转而复用底座既有的 offset。
### 两套游标制,各归其位
模型保留消费游标(`job_output` 语义不变,本次工作零 prompt/工具改动);观察者拿任意多读者可共持的绝对 offset。这正是最强的同行范式(Codex:模型用破坏性 drain,基础设施用 seq 游标读取),也是 wire 路径可证明"不偷字节"的原因。
## 曾考虑的替代方案
**持久 `activity/output` 会话事件 + conversation node。** 回放免费,但上述日志膨胀账加上对每个 follower 的保留压力否掉了它;transcript 侧的持久故事仍是生产者的工具结果。
**给 `SessionControlFrame` 加输出。** 少一条流,但整值语义会按 chunk 重发累计缓冲——恰好在会增长的数据上 O(n²)。
**信号帧 + RPC 拉取。** subagent catalog 的形态;因 job 显示 note 记录的权威割裂机制而否决,且它在结算时刻失效得最难看(行还在跑、流已死)。
**在 jobs seam 内做观察。** 一个集成点,但只对 jobs;前台运行、terminal 与 workflow 叙述各需另一套机制,注册表还得在生产者自己的缓冲旁再养一份。
## 测试
单测钉住注册表(offset、淘汰、UTF-8 安全裁尾、lossy 读取、first-wins 结算、owner 清理、scope 投递、监听容错、HMR 可逆、带行配置的 Loader 组合)、泵(节奏、每源游标、lossy `gapBefore`、结算排干、定时器卫生)、controller(baseline/桶帧、锚点/输出/状态排序、续传、帧切分、有栅栏的有主读取、abort)、客户端模型与引用计数流,以及两类生产者接入(bash 用真实子进程输出,含"绝不偷模型游标"断言;pwsh 用脚本化读取器;workflow 用事件驱动镜像,含可选性承诺要求的 throwing-registry 容错)。无钥 web e2e 经完整组合驱动真实 `run_in_background` bash,钉住命令仍在运行时流式面板与注册表 kill 后已结算面板的 ARIA golden。
## 后果
Web 客户端在零模型面改动下获得后台 bash/pwsh 实时输出与 workflow 叙述;任何插件对着一个可选服务用三个调用(`open`/`append`/`end`)即可接入。代价是多一个 seam 家族与每生产者一个接入点;最初的双头部列表重复已折并为[单一任务列表](../../archived/feature/2026-08-25-unified-task-list.md)。暂缓项记录在各包 README:前台流式(需 shell seam 的 run/observe 变体)、terminal `pty-send` 观察(需给 terminal 缓冲加非消费面)、人工 kill 控件(被 jobs `reported` 契约阻塞)、SDK/ACP 消费端、重启后的持久回放。
+1
View File
@@ -126,6 +126,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
- `@earendil-works/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
- `[email protected]` — [`patches/[email protected]`](patches/[email protected])
+1 -1
View File
@@ -45,7 +45,7 @@ export function installDesktopUpdateTaskControl(ctx: Context): (action: 'inspect
const liveAgents = agents.list()
return liveAgents.some(agent => agent.status === 'running'
|| agent.inbox.nextTurn.length > 0 || agent.inbox.nextStep.length > 0)
|| [undefined, ...liveAgents].some(agent => jobs.list(agent)
|| [undefined, ...liveAgents].some(agent => jobs.list(agent?.id)
.some(job => job.status === 'running' || job.status === 'stopping'))
}
}
+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 apps/desktop/README.md
README.md: dff2a98591b763d28b0e2d35430f8120d92f0afd
README.zh.md: 7e98bb6c2fb390f9984887b64b3a01c0a89530dc
README.md: cc7f59fdd1706a99c31a85b6ac44d11c3b15a251
README.zh.md: ae8ef98d07c3318e6cbc9440eb6820e428facb36
+2
View File
@@ -10,6 +10,8 @@ Desktop’s local native directory flow opens an Electron folder dialog attached
Creator and the Web Plugin Manager use Desktop’s bundled pnpm under Electron Node mode without requiring pnpm on PATH. The private Node launcher environment applies only to package operations.
Desktop microphone access is restricted to audio requests from the primary `dsh-app://app` frame. macOS uses system microphone authorization and a packaged usage description.
## Key technical decisions
The original artwork lives in `resources/icon.png` and `resources/icon.svg`; platform adaptations retain the whale and gradients in `resources/icon-windows.*` and `resources/icon-macos.*`. Export each platform SVG as a transparent 1024×1024 PNG. Electron-builder generates the multi-size ICO for the Windows application, installer, and uninstaller ([Windows icon requirements](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction)). The installation pages use matching artwork in both themes; the uninstaller's welcome and finish pages share `installer/assets/uninstaller-sidebar.png`, converted to a 164×314 BMP during preparation.
+2
View File
@@ -10,6 +10,8 @@ Desktop 的本地原生目录流程打开绑定应用窗口的 Electron 文件
Creator 和 Web Plugin Manager 在 Electron Node 模式下使用 Desktop 内置 pnpm,无需 PATH 中存在 pnpm。私有 Node 启动器环境仅应用于包操作。
桌面麦克风访问仅允许主 `dsh-app://app` 页面发起的音频请求。macOS 使用系统麦克风授权与随包用途说明。
## 关键技术决策
设计师原稿位于 `resources/icon.png` 和 `resources/icon.svg`;平台适配保留鲸鱼与渐变,分别位于 `resources/icon-windows.*` 和 `resources/icon-macos.*`。将各平台 SVG 导出为透明的 1024×1024 PNG。electron-builder 为 Windows 应用、安装程序和卸载程序生成多尺寸 ICO([Windows 图标要求](https://learn.microsoft.com/en-us/windows/apps/design/iconography/app-icon-construction))。安装页面在两种主题下使用匹配的图案;卸载程序的欢迎和完成页共用 `installer/assets/uninstaller-sidebar.png`,准备阶段将其转换为 164×314 BMP。
@@ -21,6 +21,7 @@ export interface DesktopElectronBuilderConfig {
{ readonly from: string, readonly to: 'icon.png' },
]
readonly mac: {
readonly extendInfo: { readonly NSMicrophoneUsageDescription: string }
readonly identity: string | undefined
readonly forceCodeSigning: boolean
readonly notarize: boolean
@@ -131,6 +131,7 @@ export function createElectronBuilderConfig(
identity: macOSSigning?.signingIdentity,
forceCodeSigning: true,
hardenedRuntime: true,
extendInfo: { NSMicrophoneUsageDescription: 'DeepSeek Harness uses your microphone to transcribe speech into message drafts.' },
// ASAR-unpacked native runtime files are pre-signed; PAK resources are sealed by their enclosing bundle.
signIgnore: ['/Contents/Resources/app\\.asar\\.unpacked/dsh(?:/|$)', '/Contents/Resources/runtime/primary-runtime(?:/|$)', '\\.pak$'],
notarize: true,
+2
View File
@@ -22,6 +22,7 @@ import { resolveDesktopPaths } from './paths.ts'
import { DesktopProjectManager } from './project-manager.ts'
import { DesktopHostProcess, DesktopHostUncleanExitError } from './host-process.ts'
import { installDesktopDirectoryPicker } from './directory-picker.ts'
import { installMicrophonePermissions } from './microphone-permissions.ts'
import { DesktopBackendController } from './backend-controller.ts'
import { DESKTOP_IPC, SCHEME, assertDesktopSender, type DesktopUpdateState } from './ipc.ts'
import { formatDesktopMessage, resolveDesktopLocale } from './locale.ts'
@@ -429,6 +430,7 @@ async function main(): Promise<void> {
})
installDesktopDirectoryPicker(() => mainWindow)
installMicrophonePermissions(session.defaultSession, () => mainWindow?.webContents)
ipcMain.handle(DESKTOP_IPC.boot, async (event) => {
assertDesktopSender(event, ['app'])
@@ -0,0 +1,31 @@
/** Microphone access belongs to the primary application frame and the operating system. */
import { systemPreferences, type Session, type WebContents } from 'electron'
function applicationFrame(url: string): boolean {
try {
const parsed = new URL(url)
return parsed.protocol === 'dsh-app:' && parsed.hostname === 'app'
} catch (_error) { return false /* Invalid frame URLs cannot request microphone access. */ }
}
/**
* Handle microphone requests from the owned application window; other permissions retain Electron's defaults.
* @param session - application's browser session.
* @param primary - current primary window contents, absent while no window is open.
*/
export function installMicrophonePermissions(session: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>, primary: () => WebContents | undefined): void {
session.setPermissionCheckHandler((contents, permission, origin, details) => {
if (permission !== 'media') return true
return contents != null && contents === primary() && details.isMainFrame
&& applicationFrame(origin) && details.mediaType === 'audio'
&& (process.platform !== 'darwin' || systemPreferences.getMediaAccessStatus('microphone') === 'granted')
})
session.setPermissionRequestHandler((contents, permission, callback, details) => {
if (permission !== 'media') { callback(true); return }
const allowed = contents === primary() && details.isMainFrame && applicationFrame(details.requestingUrl)
&& 'mediaTypes' in details && details.mediaTypes.length === 1 && details.mediaTypes[0] === 'audio'
if (!allowed) { callback(false); return }
if (process.platform !== 'darwin') { callback(true); return }
void systemPreferences.askForMediaAccess('microphone').then(callback, () => { callback(false) })
})
}
+33 -8
View File
@@ -1,16 +1,41 @@
import { Context } from '@deepseek-ai/cordis'
import { JobId, JobRegistry } from '@deepseek-ai/dsh-jobs'
import type { JobStatus, JobView } from '@deepseek-ai/dsh-jobs'
import { SessionId } from '@deepseek-ai/dsh-session/types'
import { Readable } from 'node:stream'
import type { IncomingMessage, ServerResponse } from 'node:http'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { installDesktopUpdateTaskControl } from '../../desktop-host/src/update-tasks.ts'
type AgentState = { status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } }
type JobState = { status: 'running' | 'stopping' | 'finished' }
type AgentState = { id: SessionId; status: 'idle' | 'running'; inbox: { nextTurn: object[]; nextStep: object[] } }
let ctx: Context
let inspect: ReturnType<typeof installDesktopUpdateTaskControl>
const agents: AgentState[] = []
const jobs = new Map<AgentState | undefined, JobState[]>()
const jobs = new Map<SessionId | undefined, JobStatus[]>()
/** A registry whose only served member is the per-owner roster the inspector reads. */
class RosterOnlyJobRegistry extends JobRegistry {
readonly events = { subscribe: () => () => {} }
start(): never { throw new Error('unsupported') }
list(caller?: SessionId): JobView[] {
return (jobs.get(caller) ?? []).map((status, index) => ({
id: JobId(`bash-${index + 1}`),
kind: 'bash',
label: 'sleep 60',
...caller === undefined ? {} : { owner: caller },
status,
startedAt: 0,
output: { total: 0, earliest: 0 },
}))
}
get(): never { throw new Error('unsupported') }
read(): never { throw new Error('unsupported') }
readAt(): never { throw new Error('unsupported') }
kill(): never { throw new Error('unsupported') }
wait(): never { throw new Error('unsupported') }
attachController(): () => void { return () => {} }
}
beforeEach(() => {
agents.length = 0
@@ -18,13 +43,13 @@ beforeEach(() => {
ctx = new Context()
// Narrow service doubles exercise the inspector against a real Cordis event lifecycle.
ctx.provide('agents', { list: () => agents } as unknown as Context['agents'])
ctx.provide('jobs', { list: (agent?: AgentState) => jobs.get(agent) ?? [] } as unknown as Context['jobs'])
new RosterOnlyJobRegistry(ctx)
inspect = installDesktopUpdateTaskControl(ctx)
})
afterEach(async () => { await ctx.fiber.dispose() })
function idleAgent(): AgentState { return { status: 'idle', inbox: { nextTurn: [], nextStep: [] } } }
function idleAgent(): AgentState { return { id: SessionId('update-task-owner'), status: 'idle', inbox: { nextTurn: [], nextStep: [] } } }
function request(next: () => Promise<void>) {
const incoming = Readable.from([]) as IncomingMessage
@@ -48,10 +73,10 @@ describe('Desktop Host update task protection', () => {
it.each(['running', 'stopping'] as const)('counts global and agent-owned %s jobs', async (status) => {
const agent = idleAgent()
agents.push(agent)
for (const owner of [undefined, agent]) {
jobs.set(owner, [{ status }])
for (const owner of [undefined, agent.id]) {
jobs.set(owner, [status])
expect(await inspect('inspect')).toBe(true)
jobs.set(owner, [{ status: 'finished' }])
jobs.set(owner, ['completed'])
expect(await inspect('inspect')).toBe(false)
}
})
@@ -42,6 +42,7 @@ describe('desktop macOS release signature', () => {
const { createElectronBuilderConfig } = await import('../electron-builder.config.mjs')
const config = createElectronBuilderConfig(RELEASE_ENVIRONMENT, 'darwin', 'arm64')
expect(portablePath(config.directories.output)).toContain('/.desktop-build/targets/mac-arm64/artifacts')
expect(config.mac.extendInfo.NSMicrophoneUsageDescription).toContain('microphone')
expect(config.extraResources).toHaveLength(2)
expect(config.extraResources[0]?.to).toBe('runtime')
expect(portablePath(config.extraResources[0]?.from ?? '')).toContain('/.desktop-build/targets/mac-arm64/runtime')
+3 -1
View File
@@ -211,7 +211,9 @@ vi.mock('electron', () => ({
removeHandler: (channel: string) => { harness.handlers.delete(channel) },
},
Menu: { setApplicationMenu: harness.menu.setApplicationMenu, buildFromTemplate: harness.menu },
session: { defaultSession: { webRequest: { onBeforeSendHeaders: harness.socketHeaders } } },
session: { defaultSession: {
setPermissionCheckHandler: vi.fn(), setPermissionRequestHandler: vi.fn(), webRequest: { onBeforeSendHeaders: harness.socketHeaders },
} },
protocol: { registerSchemesAsPrivileged: vi.fn(), handle: harness.protocolHandle },
powerMonitor: harness.powerMonitor,
}))
@@ -0,0 +1,69 @@
/** Desktop media permissions exclude external pages, subframes and camera requests. */
import type { Session, WebContents } from 'electron'
import { afterEach, expect, it, vi } from 'vitest'
import { installMicrophonePermissions } from '../src/microphone-permissions.ts'
const access = vi.hoisted(() => ({ getMediaAccessStatus: vi.fn(() => 'granted'), askForMediaAccess: vi.fn(async () => true) }))
vi.mock('electron', () => ({ systemPreferences: access }))
afterEach(() => { vi.unstubAllGlobals(); vi.resetAllMocks() })
function fixture(platform: NodeJS.Platform = 'darwin') {
vi.stubGlobal('process', { ...process, platform })
const setPermissionCheckHandler = vi.fn<Session['setPermissionCheckHandler']>()
const setPermissionRequestHandler = vi.fn<Session['setPermissionRequestHandler']>()
const primary = {} as WebContents
installMicrophonePermissions({ setPermissionCheckHandler, setPermissionRequestHandler }, () => primary)
return { primary, check: setPermissionCheckHandler.mock.calls[0]![0]!, request: setPermissionRequestHandler.mock.calls[0]![0]! }
}
it('allows only audio checks from the owned primary application frame', () => {
const f = fixture(), details = { isMainFrame: true, mediaType: 'audio' as const }
expect(f.check(f.primary, 'media', 'dsh-app://app', details)).toBe(true)
expect(f.check(null, 'media', 'dsh-app://app', details)).toBe(false)
expect(f.check({} as WebContents, 'media', 'dsh-app://app', details)).toBe(false)
expect(f.check(f.primary, 'media', 'https://example.com', details)).toBe(false)
expect(f.check(f.primary, 'media', 'invalid', details)).toBe(false)
expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, isMainFrame: false })).toBe(false)
expect(f.check(f.primary, 'media', 'dsh-app://app', { ...details, mediaType: 'video' })).toBe(false)
expect(f.check(f.primary, 'clipboard-sanitized-write', 'dsh-app://app', details)).toBe(true)
})
it.each(['not-determined', 'granted', 'denied', 'restricted', 'unknown'] as const)(
'reports existing macOS microphone authorization for %s', (status) => {
const f = fixture()
access.getMediaAccessStatus.mockReturnValueOnce(status)
expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(status === 'granted')
expect(access.getMediaAccessStatus).toHaveBeenCalledExactlyOnceWith('microphone')
expect(access.askForMediaAccess).not.toHaveBeenCalled()
},
)
it.each([true, false])('waits for the first macOS authorization decision: %s', async (allowed) => {
const f = fixture(), decision = Promise.withResolvers<boolean>(), done = vi.fn()
access.getMediaAccessStatus.mockReturnValue('not-determined')
access.askForMediaAccess.mockReturnValueOnce(decision.promise)
try {
f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] })
expect(access.askForMediaAccess).toHaveBeenCalledExactlyOnceWith('microphone')
expect(done).not.toHaveBeenCalled()
} finally { decision.resolve(allowed); await decision.promise }
expect(done).toHaveBeenCalledExactlyOnceWith(allowed)
})
it('requests macOS microphone access and reports operating-system rejection', async () => {
const f = fixture(), done = vi.fn(), details = { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] }
f.request(f.primary, 'media', done, details)
await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(true) })
expect(access.askForMediaAccess).toHaveBeenCalledWith('microphone')
access.askForMediaAccess.mockRejectedValueOnce(new Error('permission denied'))
f.request(f.primary, 'media', done, details)
await vi.waitFor(() => { expect(done).toHaveBeenLastCalledWith(false) })
for (const denied of [{ ...details, mediaTypes: ['audio', 'video'] }, { ...details, mediaTypes: [] },
{ ...details, isMainFrame: false }, { isMainFrame: true, requestingUrl: 'dsh-app://app/' }]) {
f.request(f.primary, 'media', done, denied); expect(done).toHaveBeenLastCalledWith(false)
}
f.request(f.primary, 'clipboard-sanitized-write', done, details)
expect(done).toHaveBeenLastCalledWith(true)
})
it.each(['win32', 'linux'] as const)('leaves system authorization to Chromium on %s', (platform) => {
const f = fixture(platform), done = vi.fn()
expect(f.check(f.primary, 'media', 'dsh-app://app', { isMainFrame: true, mediaType: 'audio' })).toBe(true)
f.request(f.primary, 'media', done, { isMainFrame: true, requestingUrl: 'dsh-app://app/', mediaTypes: ['audio'] })
expect(done).toHaveBeenCalledWith(true)
expect(access.askForMediaAccess).not.toHaveBeenCalled()
})
+3 -2
View File
@@ -404,8 +404,9 @@ describe('web e2e: agent-preset selection', () => {
await compareOrRefreshGolden(HEADER_EXPECTED, snapshot, MODE)
expect(snapshot).toContain('Minimal mode')
expect(snapshot).toContain('button "1 subagent"')
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('Minimal mode'))
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "More actions"'))
// The preset label leads the header band; delegation navigation follows it.
expect(snapshot.indexOf('Minimal mode')).toBeLessThan(snapshot.indexOf('button "1 subagent"'))
expect(snapshot.indexOf('button "1 subagent"')).toBeLessThan(snapshot.indexOf('button "More actions"'))
// Static chrome, not a control: the header can only report a composition
// the host would refuse to change.
expect(snapshot).not.toContain('button "Minimal mode"')
+3 -1
View File
@@ -60,7 +60,6 @@ interface ControlBaseline {
readonly type: 'baseline'
readonly value: {
readonly queues: Readonly<Record<string, readonly unknown[]>>
readonly jobs: Readonly<Record<string, readonly unknown[]>>
readonly approvals: readonly unknown[]
readonly questions: readonly unknown[]
readonly projections: Readonly<Record<string, {
@@ -179,6 +178,9 @@ export function createAssembledRemote(options: AssembledRemoteOptions = {}): Ass
mock.stream('session/control', (_args, stream) => {
stream.push(structuredClone(fixture.control))
})
mock.stream('job/list', (_args, stream) => {
stream.push({ type: 'rows', jobs: [] })
})
mock.stream('workspace/follow', (_args, stream) => {
stream.push({
type: 'baseline',
+19 -17
View File
@@ -1,6 +1,6 @@
// Session-header background jobs driven by a real `ctx.jobs` entry. No model
// call is involved.
import { readFile } from 'node:fs/promises'
import { readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
@@ -22,9 +22,12 @@ const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
const MODE = webSnapshotMode()
const SEED_ID = 'background-job-list-web-e2e'
// Long enough that the running assertions never race the process exiting on
// their own; the test kills it explicitly to reach the settled state.
const COMMAND = 'sleep 45'
// A hold on a barrier file the test owns in the job's cwd: the process never
// exits on its own, so no CI stall can settle it before the scenario kills it.
// The bounded loop only caps an orphan's lifetime if the runner dies before
// `afterAll` releases the barrier.
const RELEASE = '.background-job-list.release'
const COMMAND = `for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done`
/**
* Wait for opening a session to publish its live Agent.
@@ -48,7 +51,6 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let agent: Agent
let jobId: JobId
beforeAll(async () => {
scaffold = await launchWebScaffold({})
@@ -73,12 +75,14 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
}, 120_000)
afterAll(async () => {
if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '')
await browser?.close()
await scaffold?.close()
})
it('shows a running background job in the session header without a refresh', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-running'))
it('shows a running background job in the header without a refresh, then flips it to cancelled on settlement', async () => {
let phase = 'running'
onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`))
// Polling for zero would pass at t=0 before delivery and prove nothing.
const trigger = page.getByRole('button', { name: '1 background job running' })
expect(await trigger.count()).toBe(0)
@@ -93,7 +97,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
const matched = /\bbash-\d+\b/.exec(reported)
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
jobId = JobId(matched[0])
const jobId = JobId(matched[0])
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
@@ -101,24 +105,22 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await row.waitFor({ timeout: 10_000 })
await expect.poll(() => row.textContent()).toContain(COMMAND)
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE)
const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
it('flips the open list to the cancelled outcome when the registry settles it', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-job-settled'))
expect(scaffold.ctx.jobs.kill(jobId, agent, 'web e2e cancellation')).toBe('requested')
phase = 'settled'
expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested')
const idle = page.getByRole('button', { name: '1 background job', exact: true })
await idle.waitFor({ timeout: 20_000 })
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 60_000)
}, 90_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md'])
+49 -9
View File
@@ -89,8 +89,8 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
let outsideRoot: string | undefined
let nativeRoot: string | undefined
let openLog = ''
const opened = async (): Promise<Array<{ path: string; action: 'open' | 'reveal' }>> =>
(await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' })
const opened = async (): Promise<Array<{ path: string; action: 'open' | 'reveal' | 'application' }>> =>
(await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' })
beforeAll(async () => {
outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
@@ -102,10 +102,34 @@ describe.skipIf(MODE === 'record')('web e2e: document preview through Files', ()
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
await writeFile(join(nativeRoot, command), `#!/usr/bin/env node
const fs = require('node:fs');
const path = process.argv[2] === '-R' ? process.argv[3] : process.argv[2];
const action = process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open';
const path = process.argv[2] === '-a' ? process.argv[4] : process.argv[2] === '-R' ? process.argv[3] : process.argv[2];
const action = process.argv[2] === '-a' ? 'application' : process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open';
fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n');
`, { mode: 0o700 })
if (process.platform === 'darwin') {
const apps = [
{ id: '/Applications/Test Player.app', name: 'Test Player', default: true, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` },
{ id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null },
]
await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(JSON.stringify(apps))});\n`, { mode: 0o700 })
}
if (process.platform === 'linux') {
const data = join(nativeRoot, 'data')
await mkdir(join(data, 'applications'), { recursive: true })
const icon = join(nativeRoot, 'icon.png')
await writeFile(icon, TINY_PNG)
await writeFile(join(data, 'applications', 'test.desktop'), `[Desktop Entry]\nName=Test Player\nIcon=${icon}\n`)
await writeFile(join(data, 'applications', 'other.desktop'), '[Desktop Entry]\nName=Other Player\n')
await writeFile(join(nativeRoot, 'gio'), `#!/usr/bin/env node
const fs = require('node:fs');
if (process.argv[2] === 'info') process.stdout.write('standard::content-type: video/mp4');
else if (process.argv[2] === 'mime') process.stdout.write('Default application for video/mp4: test.desktop\\nRegistered applications:\\n test.desktop\\n other.desktop\\n');
else if (process.argv[2] === 'launch') fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[4], action: 'application' }) + '\\n');
else process.exit(1);
`, { mode: 0o700 })
vi.stubEnv('XDG_DATA_HOME', data)
vi.stubEnv('XDG_DATA_DIRS', '')
}
vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
}
// The Open In rows carry the default-application controls; the SSH marker
@@ -747,13 +771,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
await headerOpen.waitFor({ timeout: 15_000 })
const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]')
await emptyOpen.waitFor({ timeout: 15_000 })
const prominent = unsupported.locator('[data-open-target="file"][data-size="large"]')
expect((await prominent.boundingBox())?.height).toBe(40)
expect((await unsupported.locator('[data-open-path]').boundingBox())?.height).toBe(24)
await successShot(page, 'unsupported')
sections.push([
'## Unviewable binary', '',
'- State: unsupported',
`- Line: ${unsupportedLine.trim()}`,
`- Header control: ${await headerOpen.innerText()}`,
`- Empty-state control: ${await emptyOpen.innerText()}`,
`- Header control has no text: ${String(await headerOpen.innerText() === '')}`,
`- Empty-state control has text: ${String((await emptyOpen.innerText()).length > 0)}`,
].join('\n'))
if (STUB_OPENER) {
// Real Host gestures against the stubbed opener: default application from the empty state, reveal from the header menu.
@@ -761,19 +788,32 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) +
await emptyOpen.click()
await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(1)
await unsupported.locator('[data-open-path-more]').click()
await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click()
await page.getByRole('menuitem', { name: /^Show file location/ }).click()
await expect.poll(async () => (await opened()).length, { timeout: 15_000 }).toBe(2)
const gestures = await opened()
expect(gestures[0]).toEqual({ path: clip, action: 'open' })
expect(gestures[0]?.action).toBe('open')
expect([clip, cwd]).toContain(gestures[0]?.path)
expect(gestures[1]?.action).toBe('reveal')
expect([clip, cwd]).toContain(gestures[1]?.path)
if (STUB_OPENER) {
await expect.poll(() => headerOpen.locator('img').count()).toBe(1)
await prominent.getByRole('button', { name: 'More ways to open' }).click()
await page.getByRole('menuitem', { name: 'Test Player (default)', exact: true }).waitFor()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'applications.expected.md'), await page.getByRole('menu').ariaSnapshot(), MODE)
await page.getByRole('menuitem', { name: 'Other Player', exact: true }).click()
await expect.poll(async () => (await opened()).length).toBe(3)
expect((await opened())[2]).toEqual({ path: clip, action: 'application' })
await headerOpen.click()
await expect.poll(async () => (await opened()).length).toBe(4)
expect((await opened())[3]).toEqual({ path: clip, action: 'open' })
}
// Gesture facts stay out of the golden: the stub does not run on Windows.
expect(await page.getByRole('alert').count()).toBe(0)
}
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml'])
await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'applications.expected.md', 'paging.patch.yml'])
})
})
@@ -1,6 +1,5 @@
- navigation "Session hierarchy":
- text: Seeded turn /
- button "1 subagent"
- navigation "Session hierarchy": Seeded turn
- text: Minimal mode
- button "1 subagent"
- button "More actions"
- button "Open right sidebar"
@@ -0,0 +1,5 @@
- textbox "Describe what you want to build, / commands, @ files or sessions":
- paragraph: Draft
- button "Cancel activity"
- button "Insert result"
- button "Send message"
-5
View File
@@ -265,11 +265,6 @@
"fx-beta": [],
"fx-gamma": []
},
"jobs": {
"fx-alpha": [],
"fx-beta": [],
"fx-gamma": []
},
"approvals": [],
"questions": [],
"projections": {
@@ -0,0 +1,46 @@
/** A published-protocol consumer of composer activities and bundle activation guidance. */
window.__ModuleLoader__.load({
id: '@fixture/input-extension',
factory(require) {
const React = require('react')
const { Button, Modal, IconApiOutlineRegular } = require('@deepseek-ai/dsh-client-ui-primitives')
const h = React.createElement
return {
inject: ['slots', 'locale'],
apply(ctx) {
const labels = {
start: 'Input activity', cancel: 'Cancel activity', insert: 'Insert result', stale: 'Draft changed',
setup: 'Input plugin setup', details: 'Open setup', later: 'Later', config: 'Input plugin configuration',
}
ctx.effect(() => ctx.locale.register('fixtureInput', { en: labels, zh: labels }))
ctx.slots.inject('conversation.input.activity', () => ctx.slots.register({
name: 'conversation.input.activity', locale: 'fixtureInput',
}, ({ inputActions, onActiveChange, locked, t }) => {
const [span, setSpan] = React.useState(null)
const [stale, setStale] = React.useState(false)
React.useEffect(() => () => onActiveChange(false), [onActiveChange])
const close = () => { setSpan(null); setStale(false); onActiveChange(false) }
return span === null
? h(Button, { disabled: locked, 'aria-label': t('start'), style: { width: 32, height: 32, padding: 0 }, onClick: () => {
setSpan(inputActions.captureInsertion()); onActiveChange(true)
} }, h(IconApiOutlineRegular, { size: 16 }))
: h('div', null,
h(Button, { onClick: close }, t('cancel')),
h(Button, { onClick: () => {
if (inputActions.insertText(' from plugin', span)) close()
else setStale(true)
} }, t('insert')),
stale ? h('span', { role: 'status' }, t('stale')) : null)
}))
ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register({
name: 'plugins.bundle.config', key: '@fixture/input-extension', locale: 'fixtureInput',
}, ({ t }) => h('p', null, t('config'))))
ctx.slots.inject('plugins.bundle.activation', () => ctx.slots.register({
name: 'plugins.bundle.activation', key: '@fixture/input-extension', locale: 'fixtureInput',
}, ({ onDismiss, onOpenDetails, t }) => h(Modal, {
open: true, title: t('setup'), closeLabel: t('later'), onClose: onDismiss,
}, h(Button, { onClick: onDismiss }, t('later')), h(Button, { onClick: onOpenDetails }, t('details')))))
},
}
},
})
@@ -0,0 +1,3 @@
- insert:
- id: fixture-input-extension
name: '@fixture/input-extension'
@@ -0,0 +1,2 @@
/** Host companion for the optional input extension fixture. */
export function apply() {}
@@ -0,0 +1,12 @@
{
"name": "@fixture/input-extension",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "index.js",
"exports": { ".": "./index.js", "./client": "./client.js" },
"dsh": {
"bundle": { "patch": "./cordis.patch.yml" },
"client": { "platform": "web" }
}
}
@@ -0,0 +1,82 @@
/** Optional input contributions mount through the real Loader and preserve editor ownership. */
import { fileURLToPath } from 'node:url'
import { chromium, type Browser } from 'playwright'
import { expect, it, onTestFinished } from 'vitest'
import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { connectFreshWorkspace, newEnglishPage } from './support.ts'
const bundle = fileURLToPath(new URL('./fixtures/plugins/fixture-input-extension', import.meta.url))
const expected = fileURLToPath(new URL('./expected/input-plugin-extensions.expected.md', import.meta.url))
async function fixture(enabled: boolean) {
const resources: { scaffold?: WebScaffold; browser?: Browser } = {}
onTestFinished(async () => {
try { await resources.browser?.close() } finally { await resources.scaffold?.close() }
})
const scaffold = await launchWebScaffold({ profile: { packages: [{ dir: bundle, enabled }] } })
resources.scaffold = scaffold
const browser = await chromium.launch()
resources.browser = browser
const page = await newEnglishPage(browser), tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl)
return { scaffold, page, tripwire }
}
it('expands an input activity and inserts one undoable result without overwriting subsequent edits', async () => {
const { scaffold, page, tripwire } = await fixture(true)
await connectFreshWorkspace(page, scaffold.workspaceCwd)
const input = page.locator('[data-composer-input]')
const start = page.getByRole('button', { name: 'Input activity', exact: true })
const model = page.getByRole('button', { name: /^Select model, current/ })
const send = page.getByRole('button', { name: 'Send message', exact: true })
await input.fill('Draft')
await input.press('End')
for (const width of [1280, 420]) {
await page.setViewportSize({ width, height: 900 })
await expect.poll(async () => {
const activityBox = await start.boundingBox(), modelBox = await model.boundingBox(), sendBox = await send.boundingBox()
return activityBox !== null && modelBox !== null && sendBox !== null
&& activityBox.x >= modelBox.x + modelBox.width && activityBox.x + activityBox.width <= sendBox.x
}).toBe(true)
}
await start.click()
expect(await model.isVisible()).toBe(false)
expect(await send.isVisible()).toBe(true)
await compareOrRefreshGolden(expected,
await captureStableAria(page, '[data-composer-card]', scaffold.workspaceCwd), webSnapshotMode())
await page.getByRole('button', { name: 'Insert result', exact: true }).click()
await expect.poll(() => input.innerText()).toBe('Draft from plugin')
await input.press('ControlOrMeta+z')
await expect.poll(() => input.innerText()).toBe('Draft')
await start.click()
await input.fill('Newer draft')
await page.getByRole('button', { name: 'Insert result', exact: true }).click()
await page.getByRole('status').filter({ hasText: 'Draft changed' }).waitFor()
expect(await input.innerText()).toBe('Newer draft')
await page.getByRole('button', { name: 'Cancel activity', exact: true }).click()
expect(await model.isVisible()).toBe(true)
expect(tripwire.pageErrors).toEqual([])
})
it('offers bundle guidance only after explicit activation and navigates to its configuration', async () => {
const { page, tripwire } = await fixture(false)
await page.getByRole('button', { name: 'Plugins', exact: true }).click()
const toggle = page.getByRole('switch', { name: 'Enable @fixture/input-extension', exact: true })
await toggle.waitFor()
const dialog = page.getByRole('dialog', { name: 'Input plugin setup', exact: true })
expect(await dialog.count()).toBe(0)
await toggle.click()
await dialog.waitFor()
await dialog.getByRole('button', { name: 'Later', exact: true }).last().click()
await dialog.waitFor({ state: 'hidden' })
expect(await toggle.getAttribute('aria-checked')).toBe('true')
await toggle.click()
await page.getByRole('switch', { name: 'Enable @fixture/input-extension', exact: true, checked: false }).waitFor()
await toggle.click()
await dialog.getByRole('button', { name: 'Open setup', exact: true }).click()
await page.getByText('Input plugin configuration', { exact: true }).waitFor()
expect(await dialog.count()).toBe(0)
expect(tripwire.pageErrors).toEqual([])
})
+136
View File
@@ -0,0 +1,136 @@
// Session-header job list driven by a real background bash run: the job row
// arrives over the job roster stream, expanding it opens the output
// observation stream, and the panel shows the process's real output while it
// is still running. No model call is involved.
import { readFile, writeFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import { JobId } from '@deepseek-ai/dsh-jobs'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/fresh-round-trip/session.v2.jsonl', 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-job-stream-web-e2e'
// One fixed line of early output, then a hold on a barrier file the test owns
// in the job's cwd: the process never exits on its own, so no CI stall can
// settle it before the scenario kills it. The bounded loop only caps an
// orphan's lifetime if the runner dies before `afterAll` releases the barrier.
const RELEASE = '.live-job-stream.release'
const COMMAND = `printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e ${RELEASE} ] && break; sleep 0.2; done`
/**
* Wait for opening a session to publish its live Agent.
* @param scaffold - the booted web scaffold.
* @param sessionId - the opened session's identity.
* @returns the registered Agent instance.
*/
async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<Agent> {
const deadline = Date.now() + 30_000
for (;;) {
const found = scaffold.ctx.agents.get(sessionId)
if (found !== undefined) return found
if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
await new Promise(resolve => setTimeout(resolve, 100))
}
}
describe.skipIf(MODE === 'record')('web e2e: live job stream', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
let agent: Agent
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
browser = await chromium.launch()
page = await newEnglishPage(browser)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
const groupRow = page.locator('[role="treeitem"]').first()
await groupRow.waitFor({ timeout: 15_000 })
await groupRow.click()
const sessionRow = page.locator('[role="treeitem"]').nth(1)
await sessionRow.waitFor({ timeout: 10_000 })
await sessionRow.click()
// Opening the session drives the Host's ordinary Agent resolution; the
// job owner must be that exact live instance, never a second one.
agent = await liveAgent(scaffold, SessionId(SEED_ID))
}, 120_000)
afterAll(async () => {
if (scaffold !== undefined) await writeFile(join(scaffold.workspaceCwd, RELEASE), '')
await browser?.close()
await scaffold?.close()
})
it('streams a running job\'s real output into the expanded panel, then settles it in place on kill', async () => {
let phase = 'streaming'
onTestFailed(() => saveFailureShot(page, `web-e2e-live-job-${phase}`))
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-job-stream-e2e'),
name: 'bash',
arguments: { command: COMMAND, description: 'Emit one live line then hold', run_in_background: true },
agent,
})
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
const matched = /\bbash-\d+\b/.exec(reported)
if (matched === null) throw new Error(`background bash reported no job id: ${reported}`)
const jobId = JobId(matched[0])
// The job row reaches the header over the job roster stream.
await trigger.waitFor({ timeout: 15_000 })
await trigger.click()
// Expanding the row opens the output 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 })
await expand.click()
await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 })
const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(STREAMING_EXPECTED, streaming, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
phase = 'settled'
expect(scaffold.ctx.jobs.kill(jobId, agent.id, '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 background job', exact: true })
await idle.waitFor({ timeout: 20_000 })
await page.getByText('streamed-marker-line').waitFor({ timeout: 10_000 })
const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 90_000)
it('keeps its snapshot inventory closed', async () => {
await assertFixtureInventory(SNAPSHOT_DIR, ['settled.expected.md', 'streaming.expected.md'])
})
})
+1 -2
View File
@@ -60,8 +60,7 @@ describe.skipIf(MODE === 'record')('web e2e: Open In under SSH', () => {
])
expect(response.status()).toBe(200)
expect(await response.json()).toEqual({ apps: [] })
expect(await page.getByRole('button', { name: /^Open workspace in / }).count()).toBe(0)
expect(await page.getByRole('button', { name: 'Choose an app to open in', exact: true }).count()).toBe(0)
expect(await page.locator('[data-open-target="directory"]').count()).toBe(0)
expect(await page.evaluate(() => localStorage.getItem('dsh.open-in-app.choice'))).toBe('"vscode"')
const snapshot = (await captureStableAria(page, 'role=banner', scaffold.workspaceCwd))
.split(SEED_ID).join('{{seededId}}')
+1 -1
View File
@@ -128,7 +128,7 @@ describe('web e2e: requested SVG is explicitly delivered', () => {
it.skipIf(MODE === 'record')('replays the delivered file and Chinese conversation', async () => {
await assertFinalWorkspaceSnapshot(DIR, cwd)
await expect.poll(() => page.getByRole('button', { name: `${FILE} 的更多文件操作`, exact: true }).isDisabled()).toBe(true)
expect(await page.locator('[data-presented-file] [data-open-target]').count()).toBe(0)
// Delivery owns the transcript; navigation and composer chrome have separate scenarios.
const aria = await captureExpandedTurnProcessAria(page, '[data-chat-flow]', scaffold.workspaceCwd)
await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
+32 -13
View File
@@ -6,6 +6,7 @@ import { chromium, type Browser, type Page } from 'playwright'
import { unzipSync, strFromU8 } from 'fflate'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
import { tmpdir, release } from 'node:os'
import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-tool-present/types'
import {
@@ -53,10 +54,18 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`)
await mkdir(DIR, { recursive: true })
scaffold = await launchWebScaffold({
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
extraOverlayPath: fileURLToPath(new URL('./present.overlay.yml', import.meta.url)),
agentPresets: { roots: [], default: 'ptc' }, compareReplaySession: true,
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
})
// File associations belong to the desktop rather than the recorded Session.
const controller = scaffold.ctx.get('sessionController')
if (controller === undefined) throw new Error('present requires Session Controller')
const nativeQuery: unknown = Reflect.get(controller, 'fileApplications')
if (typeof nativeQuery !== 'function') throw new Error('present requires native association discovery')
Reflect.set(controller, 'fileApplications', async () => [{ id: 'test-editor', name: 'Test Editor', default: true, icon: null }])
scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'present: native association fixture')
disposeApproval = scaffold.ctx.on('approval/request', () => Promise.resolve('allowed-once'), { prepend: true })
scaffold.ctx.on('session/event', (_session, event) => { events.push(event) })
browser = await chromium.launch()
@@ -121,8 +130,20 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
}
const row = page.locator('[data-presented-files-row]')
await row.waitFor()
expect(await row.getByRole('button', { name: /More file actions/ }).count()).toBe(2)
await expect.poll(() => row.getByRole('button', { name: 'More ways to open' }).count()).toBe(2)
expect(await row.getByText('report.txt', { exact: true }).innerText()).toBe('report.txt')
const card = row.locator('[data-presented-file]').filter({ hasText: 'report.txt' })
await card.getByRole('button', { name: 'Open in Test Editor', exact: true }).hover()
await page.getByRole('tooltip', { name: 'Open in Test Editor', exact: true }).waitFor()
expect(await page.getByRole('tooltip').evaluate((tooltip) => {
const rect = tooltip.getBoundingClientRect()
const previous = tooltip.style.pointerEvents
tooltip.style.pointerEvents = 'auto'
const visible = document.elementFromPoint(rect.left + rect.width / 2, rect.bottom - 1) === tooltip
tooltip.style.pointerEvents = previous
return tooltip.parentElement === document.body && visible
})).toBe(true)
await page.mouse.move(0, 0)
const beforePreview = (await opened()).length
const column = page.locator('[data-rightbar-col]')
for (const [name, content] of [['report.txt', 'EDITED_REPORT'], ['说明.txt', 'EDITED_NOTE']] as const) {
@@ -139,19 +160,16 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
expect(downloads).toEqual([])
await page.getByRole('button', { name: 'Collapse right sidebar', exact: true }).click()
const beforeReveal = (await opened()).length
await row.getByRole('button', { name: 'More file actions for report.txt', exact: true }).click()
await row.locator('[data-presented-file]').filter({ hasText: 'report.txt' }).getByRole('button', { name: 'More ways to open', exact: true }).click()
const revealResponse = page.waitForResponse(response => response.url().includes('action=reveal') && response.request().method() === 'POST')
await page.getByRole('menuitem', { name: process.platform === 'darwin' ? /Show in Finder/ : /Open containing folder/ }).click()
await page.getByRole('menuitem', { name: 'Show file location', exact: true }).click()
expect((await revealResponse).status()).toBe(204)
expect(await row.getByRole('button', { name: 'Open report.txt in sidebar', exact: true })
.evaluate(button => button === document.activeElement)).toBe(true)
await expect.poll(opened).toHaveLength(beforeReveal + 1)
expect((await opened()).at(-1)).toEqual({ action: 'reveal', content: null, path: await realpath(process.platform === 'darwin' ? join(cwd, 'report.txt') : cwd) })
for (const [name, bytes] of [['report.txt', 'EDITED_REPORT\n'], ['说明.txt', 'EDITED_NOTE\n']] as const) {
const count = (await opened()).length
const response = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
await row.getByRole('button', { name: `More file actions for ${name}`, exact: true }).click()
await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click()
await row.locator('[data-presented-file]').filter({ hasText: name }).getByRole('button', { name: 'Open in Test Editor', exact: true }).click()
expect((await response).status()).toBe(204)
await page.waitForFunction(() => document.querySelector('[data-presented-files-row] button:disabled') === null)
expect(await opened()).toHaveLength(count + 1)
@@ -174,6 +192,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
])
expect(exported).not.toContain('EDITED_REPORT')
if (MODE !== 'record') {
await expect.poll(() => page.locator('[data-presented-file] [role="status"]').count(), { timeout: 10_000 }).toBe(0)
const aria = await captureExpandedTurnProcessAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(join(DIR, 'ui.expected.md'), aria, MODE)
await page.locator('[data-turn-process]').click()
@@ -215,9 +234,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
.find(element => element.textContent === 'report.txt'),
'report title',
)
const description = requiredElement(report.querySelector<HTMLElement>('span[role="status"]'), 'report status')
const description = requiredElement(report.querySelector<HTMLElement>('[data-presented-description]'), 'report description')
const open = requiredElement(
report.querySelector<HTMLButtonElement>('button[aria-label="Open report.txt in sidebar"]'),
report.querySelector<HTMLButtonElement>('[data-open-target] button'),
'report open action',
)
const icon = requiredElement(report.querySelector<SVGElement>('svg'), 'report icon')
@@ -250,7 +269,7 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
expect(geometry.iconWidth).toBe('20')
expect(geometry.titleFontSize).toBe('13px')
expect(geometry.descriptionFontSize).toBe('10px')
expect(geometry.openFontSize).toBe('12px')
expect(geometry.openFontSize).toBe('11px')
await page.setViewportSize({ width: 480, height: 900 })
const row = page.locator('[data-presented-files-row]')
await row.scrollIntoViewIfNeeded()
@@ -263,9 +282,9 @@ fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action, con
}
const beforeDelete = (await opened()).length
await unlink(join(cwd, 'report.txt'))
const missing = page.waitForResponse(response => response.url().includes('/api/present.open?'))
await page.locator('[data-presented-files-row]').getByRole('button', { name: 'More file actions for report.txt', exact: true }).click()
await page.getByRole('menuitem', { name: 'Open in default app', exact: true }).click()
const missing = page.waitForResponse(response => response.url().includes('/api/present.open?') && response.request().method() === 'POST')
await page.locator('[data-presented-file]').filter({ hasText: 'report.txt' })
.getByRole('button', { name: 'Open in Test Editor', exact: true }).click()
expect((await missing).status()).toBe(404)
await page.getByText('Could not open. Click to retry.', { exact: true }).waitFor()
expect(await opened()).toHaveLength(beforeDelete)
+3 -3
View File
@@ -305,8 +305,8 @@ export interface LaunchOptions {
developerTools?: boolean
/** Enable the real Open In rows with deterministic launch-environment facts. */
openInAppEnvironment?: LaunchEnvironmentSnapshot
/** Compare the replayed root session with `replayFixture`; defaults on for a manifest-owned canonical recording. */
compareReplaySession?: boolean
/** Compare the replayed root Session; `read-only` also forbids refresh writes to a borrowed fixture. */
compareReplaySession?: boolean | 'read-only'
/**
* Optional product overlay applied after the shipped Web surface and before
* the scaffold's hermetic test patches, matching the launcher's `--patch`
@@ -965,7 +965,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
await assertReplaySession(
[...observedSessions.values()],
replayFixture,
mode,
compareReplaySession === 'read-only' ? 'replay' : mode,
`http://${browserHost}:${port}`,
harnessHome,
)
+9 -1
View File
@@ -228,6 +228,13 @@ describe('web e2e: seeded history renders through cold resume', () => {
],
openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]),
})
// Application registrations belong to the host desktop, not to the recorded Session.
const controller = scaffold.ctx.get('sessionController')
if (controller === undefined) throw new Error('seeded-history requires Session Controller')
const nativeQuery: unknown = Reflect.get(controller, 'fileApplications')
if (typeof nativeQuery !== 'function') throw new Error('seeded-history requires the native file-association adapter')
Reflect.set(controller, 'fileApplications', async () => [])
scaffold.ctx.effect(() => () => { Reflect.set(controller, 'fileApplications', nativeQuery) }, 'seeded-history: native association fixture')
// Composer recording uses a child workspace; seedSession owns the scaffold root.
const sessionCwd = MODE === 'record' ? join(scaffold.workspaceCwd, 'workspace') : scaffold.workspaceCwd
await mkdir(sessionCwd, { recursive: true })
@@ -518,7 +525,8 @@ describe('web e2e: seeded history renders through cold resume', () => {
await expect.poll(() => path.textContent()).toBe(absolutePath)
expect(await path.getAttribute('title')).toBe(absolutePath)
await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('alpha\n')
await column.locator('[data-open-path-open]').waitFor({ timeout: 5_000 })
await column.getByRole('button', { name: 'Show file location', exact: true }).waitFor({ timeout: 5_000 })
await expect.poll(() => column.locator('[data-open-path-open]').isEnabled()).toBe(true)
const preview = await captureStableAria(page, '[data-textpreview-state="text"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(FILE_PREVIEW_EXPECTED, preview, MODE)
} finally {
@@ -0,0 +1,6 @@
- list "Background jobs":
- listitem:
- button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: "bash printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done signal: SIGTERM; web e2e cancellation {{duration}}"
- text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done
- button "Copy"
- text: streamed-marker-line
@@ -0,0 +1,6 @@
- list "Background jobs":
- listitem:
- button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done {{duration}} bash running
- text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done
- button "Copy"
- text: streamed-marker-line
+2
View File
@@ -51,6 +51,7 @@
"tests/office-fixture.ts",
"tests/sidebar-browser.e2e.ts",
"tests/plugin-config.e2e.ts",
"tests/input-plugin-extensions.e2e.ts",
"tests/plugin-manager.e2e.ts",
"tests/plugin-install-cancel.e2e.ts",
"tests/plugin-install-approve.e2e.ts",
@@ -135,6 +136,7 @@
"tests/subagent-interrupt-ui.e2e.ts",
"tests/sidebar-subagent-activity.e2e.ts",
"tests/background-job-list.e2e.ts",
"tests/live-job-stream.e2e.ts",
"tests/auto-review-denial.e2e.ts",
"tests/bash-abort-row.e2e.ts",
"tests/skill-tool-row.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: 5e0bb09770e013349bcc1326bea1eab9cdc3e595
architecture.zh.md: a146c93c14ef0f563babf98f7311f1e5686895cc
architecture.md: b6acbd0906610469b96b2bdcc22410440ea237ff
architecture.zh.md: ea2df78fbd4a90a6a671f4c917980e97ef02d8f8
+1 -1
View File
@@ -146,7 +146,7 @@ New behavior attaches to a documented extension point. Changing the loop itself
| Add shell execution | register a `ctx.shell` backend; the local one spawns through `ctx.subprocess` |
| 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 |
| Manage background jobs | register on `ctx.jobs`; `job_*` tools read or stop jobs |
| 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
@@ -150,7 +150,7 @@ seam 正是替换一个提供方就能改变整个产品的原因。文件系统
| 添加 shell 执行 | 注册 `ctx.shell` 后端;本地后端通过 `ctx.subprocess` spawn 进程 |
| 添加持久化终端执行 | 注册 `ctx.terminals` 后端和 `dsh-tool-terminal` |
| 添加用户命令 | 在 `ctx.commands` 上注册;它无需模型轮次即可分派 |
| 添加后台工作 | 在 `ctx.jobs` 上注册;`job_*` 工具负责收集或停止 |
| 管理后台任务 | 在 `ctx.jobs` 上注册;`job_*` 工具读取或停止任务 |
| 从外部 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: edb8cd688768ec49ee3699c3775409a66975129d
capability-seams.zh.md: b06d9378616669407b79ffa1da205924ff6ad757
capability-seams.md: eea821874c1932d7daa5f0a2aec3f18653757142
capability-seams.zh.md: c7794eae7bfd6456b7878a5b98a27fdec9f2adab
+7 -1
View File
@@ -67,6 +67,8 @@ flowchart LR
svc_sessionController["ctx.sessionController<br/>Host Session Remote controller"]
svc_sessionFileReferences["ctx.sessionFileReferences<br/>Session-addressed file-reference Remote adapter"]
svc_sessionSkillCatalog["ctx.sessionSkillCatalog<br/>Session-addressed skill Remote adapter"]
pkg_api_job_controller["api-job-controller"]
svc_jobController["ctx.jobController<br/>Host job Remote controller"]
pkg_api_settings_controller["api-settings-controller"]
svc_credentialsController["ctx.credentialsController<br/>Host credential-surface Remote controller"]
svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
@@ -259,6 +261,7 @@ flowchart LR
pkg_agent_loop --> svc_agentLoop
pkg_agent_presets --> svc_agentPresets
pkg_api_gateway --> svc_typertGateway
pkg_api_job_controller --> svc_jobController
pkg_api_session_controller --> svc_sessionController
pkg_api_session_controller --> svc_sessionFileReferences
pkg_api_session_controller --> svc_sessionSkillCatalog
@@ -432,8 +435,10 @@ flowchart LR
svc_invariants --> pkg_agent_loop
svc_invariants --> pkg_scope
svc_invariants --> pkg_session
svc_jobs --> pkg_api_job_controller
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
@@ -555,6 +560,7 @@ flowchart LR
| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Owns Session commands, cold reads, durable-event following, live control state, model catalogs, workspace opening, and Agent activation policy. |
| `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Delegates file-reference discovery through the Session Controller's established Agent lookup policy. |
| `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | Lists the Session composition's user-invocable skills without activating a cold Agent. |
| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | Streams one background job's observation record over the generated Remote namespace; the roster stays on the session control stream. |
| `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | Projects the credential-reference seam onto the generated Remote namespace: batch fan-out, view projection, and refusal mapping live here, not on the seam Definition. |
| `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.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | Serves stat, paged text, byte windows, directory listings, and the change feed for files inside a Session's workspace root, confined by lstat, containment, and a stat re-check. |
@@ -608,7 +614,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.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
| `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). |
+7 -1
View File
@@ -69,6 +69,8 @@ flowchart LR
svc_sessionController["ctx.sessionController<br/>Host Session Remote controller"]
svc_sessionFileReferences["ctx.sessionFileReferences<br/>Session-addressed file-reference Remote adapter"]
svc_sessionSkillCatalog["ctx.sessionSkillCatalog<br/>Session-addressed skill Remote adapter"]
pkg_api_job_controller["api-job-controller"]
svc_jobController["ctx.jobController<br/>Host job Remote controller"]
pkg_api_settings_controller["api-settings-controller"]
svc_credentialsController["ctx.credentialsController<br/>Host credential-surface Remote controller"]
svc_settingsController["ctx.settingsController<br/>Host settings-surface Remote controller"]
@@ -261,6 +263,7 @@ flowchart LR
pkg_agent_loop --> svc_agentLoop
pkg_agent_presets --> svc_agentPresets
pkg_api_gateway --> svc_typertGateway
pkg_api_job_controller --> svc_jobController
pkg_api_session_controller --> svc_sessionController
pkg_api_session_controller --> svc_sessionFileReferences
pkg_api_session_controller --> svc_sessionSkillCatalog
@@ -434,8 +437,10 @@ flowchart LR
svc_invariants --> pkg_agent_loop
svc_invariants --> pkg_scope
svc_invariants --> pkg_session
svc_jobs --> pkg_api_job_controller
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
@@ -557,6 +562,7 @@ flowchart LR
| `ctx.sessionController` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 负责 Session 命令、冷读取、持久事件跟随、实时控制状态、模型目录、workspace 打开与 Agent 激活策略。 |
| `ctx.sessionFileReferences` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 通过 Session Controller 的既有 Agent lookup 策略委托文件引用发现。 |
| `ctx.sessionSkillCatalog` | `core` | [`api-session-controller`](../packages/api/session-controller) | - | - | - | 在不激活冷 Agent 的前提下列出 Session 组合中允许用户调用的 skill。 |
| `ctx.jobController` | `core` | [`api-job-controller`](../packages/api/job-controller) | - | - | - | 经生成的 Remote namespace 流式发送一个后台任务的观测 record;名册仍在会话控制流上。 |
| `ctx.credentialsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把凭据引用 seam 投影到生成的 Remote namespace:批量扇出、视图投影与拒绝映射都在这里,而不在 seam Definition 上。 |
| `ctx.settingsController` | `core` | [`api-settings-controller`](../packages/api/settings-controller) | - | - | - | 把用户设置 seam 投影到生成的 Remote namespace:读取一律脱敏,所有拒绝在这里分类,而不在 seam Definition 上。 |
| `ctx.workspaceFiles` | `core` | [`api-workspace-files`](../packages/api/workspace-files) | - | - | - | 为会话工作区根内的文件提供 stat、分页文本、字节窗口、目录列举与变更流,经 lstat、包含关系与 stat 重检限定。 |
@@ -610,7 +616,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.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
| `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: 6bd14a5aa194de15463c0d63639c0f5095995f99
config-catalog.zh.md: ed0a6cbcc7b28e3875b50ba90e427ca06e90a8dc
config-catalog.md: 31b4df25d762c25deb1d591e8a44f51a0993f1c2
config-catalog.zh.md: ce3bf45a781092665652ec0298a57ca5b760add9
+34 -6
View File
@@ -201,6 +201,24 @@ export interface Config {
Source: [`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts)
<a id="deepseek-aidsh-api-job-controller"></a>
## `@deepseek-ai/dsh-api-job-controller`
Requires: `jobs` · `typert`
```ts config-catalog
/** Job Controller deployment policy. */
export interface Config {
/** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
}
```
Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts)
<a id="deepseek-aidsh-api-session-controller"></a>
## `@deepseek-ai/dsh-api-session-controller`
@@ -215,7 +233,7 @@ export interface Config {
}
```
Source: [`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts)
Source: [`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-api-settings-controller"></a>
@@ -1160,10 +1178,20 @@ export interface Config {
* omission defaults to 10.
*/
maxConcurrentJobsPerOwner?: number
/** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
/**
* Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384.
* Settlement keeps every byte the model cursor has not consumed on top of
* this cap; the first terminal model read then trims to it.
*/
settledRetainBytes?: number
/** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */
pumpPollMs?: 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:45`](../packages/jobs/jobs-local/src/index.ts)
<a id="deepseek-aidsh-llm-deepseek"></a>
@@ -3173,14 +3201,14 @@ export interface Config {
}
/**
* How an unreported completion reaches an owner that is already idle: `wakeup`
* How an uncollected completion reaches an owner that is already idle: `wakeup`
* opens a turn for it, `quiet` leaves it pending until something else wakes the
* owner. A busy owner is injected either way.
*/
export type CompletionDelivery = 'quiet' | 'wakeup'
```
Source: [`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts)
Source: [`packages/jobs/tool-jobs/src/index.ts:52`](../packages/jobs/tool-jobs/src/index.ts)
<a id="deepseek-aidsh-tool-lsp"></a>
@@ -3232,7 +3260,7 @@ export interface Config {
}
```
Source: [`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts)
Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
@@ -3417,7 +3445,7 @@ export interface Config {
}
```
Source: [`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts)
Source: [`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts)
<a id="deepseek-aidsh-tool-todo"></a>
+35 -5
View File
@@ -203,6 +203,26 @@ export interface Config {
来源:[`packages/api/gateway/src/index.ts:140`](../packages/api/gateway/src/index.ts)
<a id="deepseek-aidsh-api-job-controller"></a>
## `@deepseek-ai/dsh-api-job-controller`
需要:`jobs` · `typert`
```ts config-catalog
/** Job Controller deployment policy. */
export interface Config {
/** Coalescing window after a registry commit before the next rows or output read, in milliseconds (default 100). */
readonly observeFlushMs?: number
/** Soft byte budget per observation output frame (default 65536); one larger chunk ships whole. */
readonly observeMaxFrameBytes?: number
}
```
来源:[`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts)
<a id="deepseek-aidsh-api-session-controller"></a>
<a id="deepseek-aidsh-api-session-controller"></a>
## `@deepseek-ai/dsh-api-session-controller`
@@ -217,7 +237,7 @@ export interface Config {
}
```
来源:[`packages/api/session-controller/src/index.ts:77`](../packages/api/session-controller/src/index.ts)
来源:[`packages/api/session-controller/src/index.ts:78`](../packages/api/session-controller/src/index.ts)
<a id="deepseek-aidsh-api-settings-controller"></a>
@@ -1162,10 +1182,20 @@ export interface Config {
* omission defaults to 10.
*/
maxConcurrentJobsPerOwner?: number
/** Live ring retention per job in UTF-8 bytes; omission defaults to 262144. */
retainBytes?: number
/**
* Ring retention kept after a job settles, in UTF-8 bytes; omission defaults to 16384.
* Settlement keeps every byte the model cursor has not consumed on top of
* this cap; the first terminal model read then trims to it.
*/
settledRetainBytes?: number
/** Poll interval for a job's pull sources, in milliseconds; omission defaults to 150. */
pumpPollMs?: number
}
```
来源:[`packages/jobs/jobs-local/src/index.ts:31`](../packages/jobs/jobs-local/src/index.ts)
来源:[`packages/jobs/jobs-local/src/index.ts:45`](../packages/jobs/jobs-local/src/index.ts)
<a id="deepseek-aidsh-llm-deepseek"></a>
@@ -3175,7 +3205,7 @@ export interface Config {
}
/**
* How an unreported completion reaches an owner that is already idle: `wakeup`
* How an uncollected completion reaches an owner that is already idle: `wakeup`
* opens a turn for it, `quiet` leaves it pending until something else wakes the
* owner. A busy owner is injected either way.
*/
@@ -3234,7 +3264,7 @@ export interface Config {
}
```
来源:[`packages/shell/tool-pwsh/src/index.ts:51`](../packages/shell/tool-pwsh/src/index.ts)
来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
@@ -3419,7 +3449,7 @@ export interface Config {
}
```
来源:[`packages/terminal/tool-terminal/src/index.ts:35`](../packages/terminal/tool-terminal/src/index.ts)
来源:[`packages/terminal/tool-terminal/src/index.ts:36`](../packages/terminal/tool-terminal/src/index.ts)
<a id="deepseek-aidsh-tool-todo"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md
adding-a-tool.md: 659f87bf88ac7fc4b8bc7dd65672c86e6b4326f6
adding-a-tool.zh.md: 4804c22b22e5761e27c59392d76a322a7407af99
adding-a-tool.md: 2deb608fb3f25cb3c816150ed678fa685892ef66
adding-a-tool.zh.md: 5ad488c8b4c7f4678f11f31ea3669c425e3ea1b7
+1 -1
View File
@@ -52,7 +52,7 @@ Registration is effect-based: disposing the plugin fiber unregisters the tool. S
Gate `run_in_background` with producer config, then register through `ctx.jobs.start({ kind, label, owner: exec.agent, run })`. The registry rejects a pre-aborted invocation before the producer body; the runtime validates ownership and task-controller availability before `run()` starts work, then supplies the id, session fence, generic control tools, notices, and owner cleanup. A successful background branch returns a typed canonical handle such as `{ kind: 'background', jobId }`; its Native renderer may keep human prose such as `started background job bash-1`, but PTC mode must never parse that prose to recover the id.
The producer supplies synchronous `cancel`, non-rejecting `done` that settles after resource cleanup, and optional consuming `readOutput` with bounded-output formatting. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer.
The spec supplies synchronous `cancel`, a non-rejecting `done` that settles after resource cleanup, and either pull `output` sources the registry pumps into the job's output ring or pushes through the `JobHandle` its starter receives; `dsh-tool-jobs` renders the model's consuming read from that ring. A pre-aborted call is a failure because no task exists whose id could satisfy the successful output schema. Once `ctx.jobs.start()` publishes the id, use a task-owned cancellation signal rather than `exec.signal`: later outer-call cancellation stops waiting for the call but does not kill published work; `job_kill`, owner disposal, and service teardown own that lifetime. Foreground work remains coupled to `exec.signal`. See the [background job runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and `dsh-tool-bash` for a stream producer.
## Execution policy and observation
+1 -1
View File
@@ -52,7 +52,7 @@ export function apply(ctx: Context) {
通过 producer 配置控制 `run_in_background`,然后使用 `ctx.jobs.start({ kind, label, owner: exec.agent, run })` 注册任务。注册表会在进入 producer 主体前将已预先中止的调用判为失败;运行时会在 `run()` 启动工作前校验 owner 和任务控制器是否可用,随后提供 id、会话围栏、通用控制工具、通知和 owner cleanup。成功的后台分支会返回类型化的规范句柄,如 `{ kind: 'background', jobId }`;其 Native 渲染器可以保留 `started background job bash-1` 这类供人阅读的自然语言,但 PTC mode 绝不能通过解析该文本取得 id。
producer 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及可选的消费式 `readOutput`(负责有界输出的格式化)。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。
spec 提供同步的 `cancel`、在资源清理后 settle 且不 reject 的 `done`,以及由注册表泵入 job 输出环的拉取式 `output` 源或经 starter 收到的 `JobHandle` 推送的追加;模型的消费式读取由 `dsh-tool-jobs` 从该环渲染。预先中止的调用属于失败,因为此时没有任务,其 id 无法满足成功输出 schema。`ctx.jobs.start()` 发布 id 后,应使用任务自有的取消信号,而不是 `exec.signal`:之后取消外层调用只会停止等待本次调用,不会终止已经发布的工作;该生命周期归 `job_kill`、owner dispose 和服务 teardown 所有。前台工作仍与 `exec.signal` 耦合。流式 producer 的示例和完整约定见[后台任务运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)与 `dsh-tool-bash`。
<a id="execution-policy-and-observation"></a>
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 619103ab6f813fc9ea915cc4f6bf5b6ae585f8cb
event-producer-consumer.zh.md: 00a13ca2aa785dd253263f154627e6dd8accacc6
event-producer-consumer.md: 8101bb9aa3d6e3da1cca96a5593de5c52d6454cd
event-producer-consumer.zh.md: a57e1b7e9447939e6a6586c4743dfd39345cc15c
+5 -5
View File
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../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:381`](../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), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:583`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:589`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:596`](../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:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+5 -5
View File
@@ -23,11 +23,11 @@
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../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:381`](../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), [`workspace-changes`](../packages/deliverables/workspace-changes) |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:614`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:594`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:621`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:600`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:607`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:617`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:597`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:624`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:603`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:610`](../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:89`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
+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: 684f82c87648bbd6fa8a1e7f45405a2c2d4d9885
module-graph.zh.md: cf3bca4f97c840a052399bd3765e7cf11270c396
module-graph.md: e0f1fa5f504b04a2ee4e33e2d8fa4013f70cd7a1
module-graph.zh.md: f4ad086634178aaa79371a6adc6b9e14d2c89e64
+3 -2
View File
@@ -114,6 +114,7 @@ flowchart TD
end
subgraph group_api["packages/api"]
pkg_api_gateway["api-gateway"]
pkg_api_job_controller["api-job-controller"]
pkg_api_remotes["api-remotes"]
pkg_api_session_controller["api-session-controller"]
pkg_api_settings_controller["api-settings-controller"]
@@ -1180,7 +1181,6 @@ flowchart TD
pkg_api_session_controller --> pkg_commands
pkg_api_session_controller --> pkg_file_reference
pkg_api_session_controller --> pkg_fs
pkg_api_session_controller --> pkg_jobs
pkg_api_session_controller --> pkg_llm
pkg_api_session_controller --> pkg_native_command
pkg_api_session_controller --> pkg_scope
@@ -1325,6 +1325,7 @@ flowchart TD
| [`util-workspace-path`](../packages/util/workspace-path) | `util` | — |
| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
| [`llm`](../packages/llm/llm) | `llm` | — |
| [`api-job-controller`](../packages/api/job-controller) | `api` | — |
| [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
| [`cmdline`](../packages/boot/cmdline) | `boot` | — |
| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
@@ -1583,7 +1584,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
+3 -2
View File
@@ -116,6 +116,7 @@ flowchart TD
end
subgraph group_api["packages/api"]
pkg_api_gateway["api-gateway"]
pkg_api_job_controller["api-job-controller"]
pkg_api_remotes["api-remotes"]
pkg_api_session_controller["api-session-controller"]
pkg_api_settings_controller["api-settings-controller"]
@@ -1182,7 +1183,6 @@ flowchart TD
pkg_api_session_controller --> pkg_commands
pkg_api_session_controller --> pkg_file_reference
pkg_api_session_controller --> pkg_fs
pkg_api_session_controller --> pkg_jobs
pkg_api_session_controller --> pkg_llm
pkg_api_session_controller --> pkg_native_command
pkg_api_session_controller --> pkg_scope
@@ -1327,6 +1327,7 @@ flowchart TD
| [`util-workspace-path`](../packages/util/workspace-path) | `util` | — |
| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
| [`llm`](../packages/llm/llm) | `llm` | — |
| [`api-job-controller`](../packages/api/job-controller) | `api` | — |
| [`api-workspace-files`](../packages/api/workspace-files) | `api` | — |
| [`cmdline`](../packages/boot/cmdline) | `boot` | — |
| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
@@ -1585,7 +1586,7 @@ flowchart TD
| [`tool-subagent`](../packages/subagent/tool-subagent) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
| [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`hooks-claude-code`](../packages/hooks/hooks-claude-code) | `hooks` | [`agent`](../packages/core/agent), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
| [`api-session-controller`](../packages/api/session-controller) | `api` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`agent-presets`](../packages/preset/agent-presets), [`api-gateway`](../packages/api/gateway), [`attachment`](../packages/attachment/attachment), [`client-connection`](../packages/client/connection), [`client-file-upload`](../packages/client/file-upload), [`commands`](../packages/interaction/commands), [`file-reference`](../packages/context/file-reference), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`native-command`](../packages/util/native-command), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol), [`typert-registry`](../packages/typert/registry), [`util-time`](../packages/util/time), [`util-values`](../packages/util/values), [`util-workspace-path`](../packages/util/workspace-path), [`workspace`](../packages/workspace/workspace) |
| [`compaction-basic`](../packages/compaction/compaction-basic) | `compaction` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compaction`](../packages/compaction/compaction), [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
| [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-query`](../packages/session-query/session-query), [`session-title`](../packages/session/session-title), [`spill`](../packages/spill/spill), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`typert-protocol`](../packages/typert/protocol) |
| [`experimental-agent-team`](../packages/experimental/agent-team) | `experimental` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`subagent`](../packages/subagent/subagent), [`typert-protocol`](../packages/typert/protocol) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/persistence-catalog.md
persistence-catalog.md: 11cea4328db6e69c1ee08994a6d36fe26e308a7c
persistence-catalog.zh.md: 6207dc17023e566f73cb1e0c7cba40533a4a96ff
persistence-catalog.md: dd52352ecff33c833154a5aa933767f0b2544b4a
persistence-catalog.zh.md: 71c0866cc2499ebaf573bc078ecb9f72a32ba128
+2 -2
View File
@@ -5315,7 +5315,7 @@ Array of `number`.
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
Sources: [`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7248,7 +7248,7 @@ Sources: [`packages/core/tools/src/index.ts:34`](../packages/core/tools/src/inde
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
Sources: [`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts)
Sources: [`packages/api/session-controller/src/types.ts:401`](../packages/api/session-controller/src/types.ts)
| Property | Presence | Type |
|---|---|---|
+2 -2
View File
@@ -5317,7 +5317,7 @@ SHA-256: `5d03ba38734809bcbd2a55221bd938b3b3fc34b49bb686c6113e9e4931e3aa78`
SHA-256: `4cd48f3e5108bd6ebedf7301c3638839b8b2b9db8dbd493974177fbdeb91a01b`
来源:[`packages/api/session-controller/src/types.ts:394`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:396`](../packages/api/session-controller/src/types.ts) · [`packages/attachment/attachment/src/brand.ts:6`](../packages/attachment/attachment/src/brand.ts) · [`packages/compaction/compaction/src/brand.ts:4`](../packages/compaction/compaction/src/brand.ts) · [`packages/core/session/src/types.ts:20`](../packages/core/session/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:20`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:32`](../packages/experimental/agent-team/src/types.ts) · [`packages/experimental/agent-team/src/types.ts:8`](../packages/experimental/agent-team/src/types.ts) · [`packages/feedback/message-feedback/src/types.ts:14`](../packages/feedback/message-feedback/src/types.ts) · [`packages/goal/goal/src/types.ts:17`](../packages/goal/goal/src/types.ts) · [`packages/interaction/commands/src/brand.ts:31`](../packages/interaction/commands/src/brand.ts) · [`packages/interaction/user-approval/src/types.ts:17`](../packages/interaction/user-approval/src/types.ts) · [`packages/llm/llm-retry/src/brand.ts:4`](../packages/llm/llm-retry/src/brand.ts) · [`packages/llm/llm/src/brand.ts:16`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:31`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:43`](../packages/llm/llm/src/brand.ts) · [`packages/llm/llm/src/brand.ts:67`](../packages/llm/llm/src/brand.ts) · [`packages/schedule/schedule/src/types.ts:10`](../packages/schedule/schedule/src/types.ts) · [`packages/session/session-title/src/index.ts:42`](../packages/session/session-title/src/index.ts) · [`packages/session/session-title/src/types.ts:17`](../packages/session/session-title/src/types.ts) · [`packages/webhook/webhook/src/brand.ts:12`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:6`](../packages/webhook/webhook/src/brand.ts) · [`packages/webhook/webhook/src/brand.ts:9`](../packages/webhook/webhook/src/brand.ts) · [`packages/workflow/workflow/src/types.ts:13`](../packages/workflow/workflow/src/types.ts)
`string`
@@ -7250,7 +7250,7 @@ SHA-256: `8fcb0b44a575e998665c053643d1ab04c82f39253ebd190ac0c975b78e40b543`
SHA-256: `6a4f72e2e179e17b922f2a9392c0e1c8f8c707f32494372454850a3eb184a6e7`
来源:[`packages/api/session-controller/src/types.ts:399`](../packages/api/session-controller/src/types.ts)
来源:[`packages/api/session-controller/src/types.ts:401`](../packages/api/session-controller/src/types.ts)
| 属性 | 存在性 | 类型 |
|---|---|---|
+2 -2
View File
@@ -44608,7 +44608,7 @@
"packages/workflow/workflow/src/types.ts#WorkflowRunId"
],
"sources": [
"packages/api/session-controller/src/types.ts:394",
"packages/api/session-controller/src/types.ts:396",
"packages/attachment/attachment/src/brand.ts:6",
"packages/compaction/compaction/src/brand.ts:4",
"packages/core/session/src/types.ts:20",
@@ -58191,7 +58191,7 @@
},
"names": [],
"sources": [
"packages/api/session-controller/src/types.ts:399"
"packages/api/session-controller/src/types.ts:401"
]
},
{
+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: 4be335207b7dd94f08015ecda595a6b1cc301a7f
jobs.zh.md: 9dc390eb30901664edb47c6727a54ddd83839fa7
jobs.md: d54b1ecdd8957c162268c8abbc1df6d764695ac7
jobs.zh.md: 3b0db7c654d02ace451e73203923d816e9262230
+314 -137
View File
@@ -2,7 +2,7 @@
English | [中文](jobs.zh.md)
Types shared by long-running producers, `ctx.jobs`, and job controls. The [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) owns the design; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts).
Types shared by long-running producers, `ctx.jobs`, and job controls. The [seam consolidation Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.md) owns the current design and the [runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) its origin; this page records the exact fields and variants from [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) and the client-safe [`view.ts`](../../packages/jobs/jobs/src/view.ts) leaf.
## Ids and status
@@ -19,45 +19,83 @@ interface JobKindMap {
}
```
`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobSnapshot.detail`.
`JobStatus` is `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`; producer-specific facts belong in `JobView.progress` while the job runs and in `JobView.detail` once it settled.
## 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.
A `JobSpec` declares identity, the owning session, optional pull `output` sources, and a starter. The runtime finishes preflight before calling `run()` with the job's `JobHandle` and commits without a later failable step. Producers own execution resources; the runtime owns identity, access, lifecycle state, and the output ring.
```ts type-equiv
/**
* Producer declaration passed to {@link JobRegistry.start}. The runtime
* preflights access and cleanup before invoking {@link run}; the producer owns
* execution resources while the runtime owns identity and lifecycle state.
* execution resources while the runtime owns identity, lifecycle state, and
* the output ring.
*/
interface JobStart {
interface JobSpec {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
kind: JobKind
/** One-line model-facing label (the command; the delegation description). */
label: string
/**
* Owning session. Access is fenced by it, and the owner's live Agent must be
* the one currently registered under that id: its disposal cancels and
* awaits the job. Omitting the owner creates an unowned job, open to any
* caller until service disposal.
*/
owner?: SessionId
/**
* 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 ring
* retention: it bounds the consuming model surface, never observers.
*/
outputLimitBytes?: number
/**
* Owning live agent. Access is fenced by its session id, and agent disposal
* cancels and awaits the job. The instance must be the one currently
* registered under its agent id. Omitting the owner creates an unowned job,
* open to any caller until service disposal.
* Pull sources the registry pumps into the ring at its own cadence.
* Producers that narrate their own progress use {@link JobHandle.append}
* instead; a job may use both.
*/
owner?: Agent
output?: readonly JobOutputSource[]
/**
* 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.
* 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 ring append and progress writers.
*/
run(): JobHooks
run(job: JobHandle): JobHooks
}
```
`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. Optional `readOutput` distinguishes consuming stream jobs from final-output-only jobs.
```ts type-equiv
/**
* Producer face of one registered job, handed to {@link JobSpec.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 — writes log and drop instead of
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
interface JobHandle {
/** The registry-issued id (`<kind>-N`). */
readonly id: JobId
/**
* Append one chunk to the output ring. 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?: JobAppendOptions): void
/**
* Replace the live progress line (`3/10`, the current phase). Settlement
* clears it; the terminal reason travels in {@link JobOutcome.detail}.
* @param line - the new progress line.
*/
updateProgress(line: string): void
}
```
`JobHooks.done` resolves after the producer releases its resources, not merely when work finishes. A job whose result is a value rather than a stream — a subagent's report, a workflow's rendered result — returns it as `JobOutcome.result`; the model's first read after settlement carries it once.
```ts type-equiv
/** Hooks through which the runtime controls and observes producer work. */
@@ -74,12 +112,6 @@ interface JobHooks {
* registry record without claiming that the work stopped.
*/
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.
*/
readOutput?(): string
}
```
@@ -88,73 +120,207 @@ interface JobHooks {
interface JobOutcome {
/** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
status: 'completed' | 'killed' | 'failed'
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
/**
* Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`).
* When the job settles `killed` after a {@link JobRegistry.kill} with a
* reason, the registry appends that reason.
*/
detail?: string
/** Final output for jobs without `readOutput`; stream jobs leave it unset. */
output?: string
/**
* Return value for jobs whose result is a value rather than a stream (a
* workflow's rendered result, a subagent's report). The output ring carries
* the stream; this is handed out once by the model's next {@link JobRegistry.read}.
*/
result?: string
}
```
A pull source hands the registry a non-consuming offset reader — the subprocess `readFrom` family. The registry pumps every source at its own cadence (`pumpPollMs` on `dsh-jobs-local`) and drains it once more before settlement closes the ring, so producers fold nothing into `done`.
```ts type-equiv
/**
* A pull source the registry pumps into the job's output ring — the subprocess
* `readFrom` family. The registry owns the cadence and drains every source one
* last time before settlement closes the ring, so a producer folds nothing
* into its `done`.
*/
interface JobOutputSource {
/** Stream label attached to every chunk this source yields. */
channel?: JobChannel
/**
* Read everything captured since `fromByte` without consuming it.
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first).
* @returns the delta text, the next offset, the lossy flag, and the spill path the source currently keeps.
*/
read(fromByte: number): JobSourceRead
}
```
```ts type-equiv
/** One incremental read from a {@link JobOutputSource}. */
interface JobSourceRead {
/** Text captured since the requested offset (the whole retained tail when lossy). */
text: string
/** Whole-stream offset to resume from on the next read. */
nextOffset: number
/** True when the requested offset slid out of the source's retained window. */
lossy: boolean
/**
* Host path of a file holding the complete stream, when the source
* currently keeps an intact one. Reported on every read, so the registry
* tracks it as source metadata: a later read without it withdraws the file.
*/
spillPath?: string
}
```
## The output ring
Every job owns one bounded ring. Pull sources are pumped into it and `JobHandle.append` pushes land whole; the model consumes the ring through a registry-kept cursor (`JobRegistry.read`), any number of observers read it at absolute byte offsets (`JobRegistry.readAt`), and neither disturbs the other. `JobChannel` labels `stdout`, `stderr`, and `log`; `log` is producer narration that reaches observers only, never the model's consuming read. Settlement ends the stream and trims retention to the settled cap — the ring has no separate lifecycle. The spill file a pull source keeps is job metadata (`JobView.output.spillPaths`, refreshed by every pump read), not per-chunk metadata, so the model's dropped-output notice names it after the ring evicted the bytes and even after the gap chunk itself is gone. Browsers reach the roster and the ring through `job.list` and `job.follow`, the Remote streams of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below.
```ts type-equiv
/** One chunk of a job's output ring: absolute offset, text, channel, and loss marker. */
interface JobChunk {
/** Absolute offset of the chunk's first byte; offsets never move once assigned. */
readonly at: number
/** Chunk text exactly as appended (possibly tail-trimmed by retention). */
readonly text: string
/** Stream label, when the producer supplied one. */
readonly channel?: JobChannel
/** Bytes immediately before this chunk were lost, at the producer or to retention. */
readonly gapBefore?: true
}
```
```ts type-equiv
/** Result of one non-consuming {@link JobRegistry.readAt}. */
interface JobOutputRead {
/** Retained chunks overlapping `[from, total)`, in offset order. */
chunks: readonly JobChunk[]
/**
* Offset to resume from — the ring's current `total`. 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.
`JobView` is the one projection every reader consumes: the model tools, the browser roster, and the observation stream. `owner` carries the session id that fences access; the registry resolves the live `Agent` behind it for lifecycle cleanup and never hands the object out. `progress` is the producer's live line and is cleared at settlement; `detail` is the terminal reason, with a recorded kill reason merged in.
```ts type-equiv
/**
* A read-only projection of one job, safe to hand to listeners and tools —
* a fresh object per call, never live registry state.
* Read-only projection of one job — a fresh object per call, never live
* registry state. The model tools, the browser roster, and the observation
* stream all consume this one shape.
*/
interface JobSnapshot {
interface JobView {
/** The registry-issued id (`<kind>-N`). */
id: JobId
/** The producer kind the job was registered with. */
kind: JobKind
readonly id: JobId
/**
* The producer kind the job was registered with: a Host-registered
* `JobKind`, carried as an open string because a browser bundle or a Remote
* codec sees only the `JobKindMap` merges its own program compiles.
*/
readonly kind: string
/** The producer-supplied one-line label. */
label: string
/** Producer-owned cap for complete model-facing notices and output reads. */
outputLimitBytes?: number
/**
* Owner session id used for authorization and correlation; absent for
* unowned jobs. Completion listeners receive the exact {@link Agent}
* separately through {@link JobDoneListener}.
*/
ownerSession?: SessionId
readonly label: string
/** Owning session; absent for an unowned job, which every caller can see. */
readonly owner?: SessionId
/** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */
readonly outputLimitBytes?: number
/** Current lifecycle state. */
status: JobStatus
/** Kind-specific status detail, present once the producer supplied one (usually terminal). */
detail?: string
readonly status: JobStatus
/** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */
readonly progress?: string
/** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */
readonly detail?: string
/** Epoch ms when the job was registered. */
startedAt: number
/** Epoch ms when the job settled; absent while `running`/`stopping`. */
finishedAt?: number
readonly startedAt: number
/** Epoch ms when the job settled; absent while live. */
readonly finishedAt?: number
/**
* True when a kill, read, wait, or teardown cancel has reported or committed
* to report the terminal state. Completion reporters suppress redundant
* notices when set. Teardown claims it because the owner or service being
* destroyed leaves no reader: a reporter that opens a turn on notice would
* otherwise spend a model request per teardown layer.
* The output ring's absolute coordinates and the complete-stream files
* behind it. `total` is the offset the next chunk starts at (0 while
* nothing was written); `earliest` is the oldest retained byte, greater
* than zero exactly when retention dropped the head. `spillPaths` lists the
* spill files the job's pull sources currently keep, in source order and
* deduplicated, and is absent while no source keeps one: it outlives any
* chunk, so a reader below `earliest` can still name where the bytes went.
*/
reported: boolean
readonly output: { readonly total: number; readonly earliest: number; readonly spillPaths?: readonly string[] }
}
```
`JobRegistry.list`, `get`, `read`, `readAt`, `kill`, and `wait` each accept the caller’s `SessionId`; omitting it permits only unowned jobs, while a session may also access its own jobs.
```ts type-equiv
/** Output and post-read state returned by {@link JobRegistry.read}. */
/** Output and post-read state returned by the consuming {@link JobRegistry.read}. */
interface JobRead {
/**
* Stream kinds: the consuming delta since the previous read. Final-output
* kinds: empty while live, the terminal {@link JobOutcome.output} (or
* empty) once settled — idempotent, never consumed.
*/
text: string
/** Ring chunks appended since the model cursor, in offset order; every channel included. */
chunks: readonly JobChunk[]
/** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */
lossy: boolean
/** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */
result?: string
/** The job's state at read time. */
snapshot: JobSnapshot
job: JobView
}
```
## Events
The registry announces every commit through one filtered stream. Lifecycle events carry the projection after the commit they announce; `settled` names its cause so a completion reporter can skip a teardown; `output` carries only the id and the new total, so observers read from their own cursor and the registry never pushes payloads.
```ts type-equiv
/**
* One lifecycle or output event. Lifecycle events carry the job's projection
* after the commit they announce; `output` carries only the id and the new
* total, so an observer schedules a {@link JobRegistry.readAt} from its own
* cursor and the registry never pushes payloads.
*/
type JobEvent =
| {
/** Registration commit, progress line change, stopping transition, or removal from the visible set. */
readonly type: 'registered' | 'progress' | 'stopping' | 'removed'
readonly job: JobView
}
| {
readonly type: 'settled'
readonly job: JobView
readonly cause: JobSettleCause
}
| {
readonly type: 'output'
readonly id: JobId
/** Owning session, absent for an unowned job. */
readonly owner?: SessionId
/** The ring's total after the append (or at settlement, which ends the stream). */
readonly total: number
}
```
```ts type-equiv
/**
* Who a subscription hears about. `{ owner }` delivers that session's jobs
* plus every unowned job (the set that session can see). `{ owners: 'scope' }`
* delivers the owners composed under the subscribing context — one registry
* serves every composition in the process, and a mount under one preset must
* not hear another preset's agents. `{ owners: 'all' }` delivers everything.
*/
type JobEventFilter =
| { readonly owner: SessionId }
| { readonly owners: 'all' | 'scope' }
```
## 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 `list`, `get`, consuming `read`, non-consuming `readAt`, `kill`, and bounded `wait`, the filtered `events` stream, 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 live `Agent` registered under the owner session when the job starts. 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; `retainBytes` (default 262144) and `settledRetainBytes` (default 16384) bound each ring's live and settled retention, and `pumpPollMs` (default 150) is the pull cadence. 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 -->
@@ -164,18 +330,52 @@ The abstract [`JobRegistry`](../../packages/jobs/jobs/src/index.ts) Service Defi
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="ctxjobcontroller--jobcontroller"></a>
### `ctx.jobController` — `JobController`
Host service backing the generated `ctx.remote.job` namespace.
```ts cordis-catalog
/**
* Stream the jobs one session can see — its own plus every unowned job —
* as whole-set frames: one on open, then one after each coalesced burst of
* lifecycle commits. The stream has no natural end; the carrier closes it.
* @param request - the session whose visible set to mirror.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns the roster frames.
*/
@Remote({ mode: 'stream' }) list(request: JobListRequest, signal: AbortSignal): AsyncIterable<JobListFrame>
/**
* Stream one job's retained output from an absolute byte offset, then its
* terminal projection once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
* request's session is the fenced read's caller; the registry rejects a
* job the session cannot see and 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' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable<JobFollowFrame>
```
Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts)
<a id="ctxjobs--jobregistry-abstract-seam"></a>
### `ctx.jobs` — `JobRegistry` (abstract seam)
Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Implementations must honor these semantics:
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left.
- 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.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.
- 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 event delivery under `{ owners: 'scope' }` — 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.
- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other.
- Ring 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 and ends the stream; the ring has no separate lifecycle.
```ts cordis-catalog
/**
@@ -183,96 +383,73 @@ Implementations must honor these semantics:
* admission before starting and atomically registering work. Any preflight
* rejection leaves no job id or execution resource. A throwing starter
* leaves nothing registered; after it returns, registration cannot fail.
* Settlement records the outcome, notifies listeners, and releases waiters.
* @param spec - job identity, owner, and synchronous starter.
* @param spec - job identity, owner, output sources, and synchronous starter.
* @returns the registry-issued `<kind>-N` id.
*/
abstract start(spec: JobStart): JobId
abstract start(spec: JobSpec): JobId
/**
* List caller-owned and unowned jobs in registration order without exposing
* another session's labels.
* @param caller - reading agent; a non-agent caller sees only unowned jobs.
* @returns fresh snapshots.
* List caller-owned and unowned jobs in registration order.
* @param caller - reading session; omission sees only unowned jobs.
* @returns fresh projections.
*/
abstract list(caller?: Agent): JobSnapshot[]
abstract list(caller?: SessionId): JobView[]
/**
* Return a non-consuming snapshot without changing its read cursor or notice
* state. Throws for an unknown or foreign job.
* @param id - job to look up.
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
abstract get(id: JobId, caller?: Agent): JobSnapshot
/**
* Read the next stream delta, or the idempotent final output after settlement.
* A terminal read marks the job reported. Throws for an unknown or foreign
* job.
* @param id - job to read.
* @param caller - reading agent checked against the owner.
* @returns output text and the post-read snapshot.
*/
abstract read(id: JobId, caller?: Agent): JobRead
/**
* Request cancellation, then mark the job stopping and reported. A producer
* throw propagates without changing job state. Throws for an unknown or
* Project one job without changing its cursor. Throws for an unknown or
* foreign job.
* @param id - job to look up.
* @param caller - reading session checked against the owner.
* @returns a fresh projection.
*/
abstract get(id: JobId, caller?: SessionId): JobView
/**
* Consume the ring from the model cursor and advance it to the current
* total. After settlement the first read also carries the producer's
* result. Throws for an unknown or foreign job.
* @param id - job to read.
* @param caller - reading session checked against the owner.
* @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection.
*/
abstract read(id: JobId, caller?: SessionId): JobRead
/**
* Read retained ring output without moving the model cursor. Resume with
* a previous read's `next`; an offset inside a retained chunk returns the
* whole chunk (its `at` may precede `from`). Throws for a negative or
* non-integer offset, or an unknown or foreign job.
* @param id - job to read.
* @param from - absolute byte offset to read from (0 for the retained head).
* @param caller - reading session checked against the owner.
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
*/
abstract readAt(id: JobId, from: number, caller?: SessionId): JobOutputRead
/**
* Request cancellation, then mark the job stopping. A producer throw
* propagates without changing job state. A supplied reason is merged into
* terminal `detail` when the job settles `killed`. Throws for an unknown
* or foreign job.
* @param id - job to cancel.
* @param caller - killing agent checked against the owner.
* @param reason - logged reason forwarded to the producer.
* @param caller - killing session checked against the owner.
* @param reason - cancellation reason forwarded verbatim to the producer.
* @returns `requested` for live work, otherwise `already-finished`.
*/
abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'already-finished'
/**
* Wait for settlement or timeout without cancelling the job. Caller abort
* rejects only while the job is live; after settlement the terminal
* snapshot wins so a notice suppressed for this waiter is still delivered.
* Throws for invalid, unknown, or foreign input.
* projection wins. Rejects for an invalid timeout or an unknown or foreign
* job.
* @param id - job to wait for.
* @param timeoutMs - positive finite wait bound in milliseconds.
* @param caller - waiting agent checked against the owner.
* @param caller - waiting session checked against the owner.
* @param signal - optional cancellation of the wait itself.
* @returns snapshot at settlement or timeout.
* @returns projection at settlement or timeout.
*/
abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<JobSnapshot>
/**
* Register an effect-scoped completion listener. It receives the settlements
* of the owners its registering context's scope covers; each listener is
* contained; returned promises are observed but not awaited. No listener runs
* after service disposal.
* @param listener - receives each terminal snapshot and its exact owner.
* @returns disposer that unregisters the listener.
*/
abstract onJobDone(listener: JobDoneListener): () => void
/**
/**
* Register an effect-scoped observer of visible-set changes. It fires after
* every commit that changes what {@link list} returns for that owner —
* registration, every stopping transition (including the one teardown
* performs before it awaits a slow producer), settlement, owner-disposal
* removal, and the emptying that service disposal commits — so an observer
* re-reads rather than accumulating deltas.
*
* Delivery is owner-relative on the same terms as {@link onJobDone}: an
* observer 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.
*
* This is not a superset of {@link onJobDone}: that one delivers the terminal
* record under first-wins semantics a job controller couples to notice
* delivery, while this one carries no delivery meaning and marks nothing
* reported. Listeners are contained and never awaited.
* @param listener - receives the owner whose visible set changed, or
* `undefined` when an unowned job changed and every caller's set did.
* @returns disposer that unregisters the listener.
*/
abstract onJobsChanged(listener: JobsChangedListener): () => void
abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise<JobView>
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
@@ -284,7 +461,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
abstract attachController(name: string): () => void
```
Types: [Agent](core.md)
Types: [SessionId](core.md)
Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/src/index.ts)
<!-- END GENERATED cordis-surface -->
+314 -137
View File
@@ -2,7 +2,7 @@
[English](jobs.md) | 中文
长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 负责设计;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 中的确切字段和变体。
长时间运行的生产方、`ctx.jobs` 与任务控制命令共用的类型。[seam 收敛 Agent Note](../../.agents/notes/implemented/architecture/2026-09-03-jobs-seam-consolidation.zh.md) 负责当前设计,[运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md) 记录其起源;本页记录 [`packages/jobs/jobs/src/types.ts`](../../packages/jobs/jobs/src/types.ts) 与客户端安全叶子 [`view.ts`](../../packages/jobs/jobs/src/view.ts) 中的确切字段与变体。
## ID 与状态
@@ -19,45 +19,83 @@ interface JobKindMap {
}
```
`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实归入 `JobSnapshot.detail`。
`JobStatus` 为 `'running' | 'stopping' | 'completed' | 'killed' | 'failed'`;生产方特有的事实在运行期间归入 `JobView.progress`,结算后归入 `JobView.detail`。
## 生产方约定
`JobStart` 声明身份和启动器。运行时会在调用 `run()` 前完成预检,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问权限和生命周期状态。
`JobSpec` 声明身份、拥有者会话、可选的拉取式 `output` 源与启动器。运行时会在完成预检后携带该 job 的 `JobHandle` 调用 `run()`,随后提交注册,不再执行可能失败的步骤。生产方拥有执行资源;运行时拥有身份、访问、生命周期状态与输出环。
```ts type-equiv
/**
* Producer declaration passed to {@link JobRegistry.start}. The runtime
* preflights access and cleanup before invoking {@link run}; the producer owns
* execution resources while the runtime owns identity and lifecycle state.
* execution resources while the runtime owns identity, lifecycle state, and
* the output ring.
*/
interface JobStart {
interface JobSpec {
/** Producer kind — also the id prefix (`bash`, `subagent`, …). */
kind: JobKind
/** One-line model-facing label (the command; the delegation description). */
label: string
/**
* Owning session. Access is fenced by it, and the owner's live Agent must be
* the one currently registered under that id: its disposal cancels and
* awaits the job. Omitting the owner creates an unowned job, open to any
* caller until service disposal.
*/
owner?: SessionId
/**
* 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 ring
* retention: it bounds the consuming model surface, never observers.
*/
outputLimitBytes?: number
/**
* Owning live agent. Access is fenced by its session id, and agent disposal
* cancels and awaits the job. The instance must be the one currently
* registered under its agent id. Omitting the owner creates an unowned job,
* open to any caller until service disposal.
* Pull sources the registry pumps into the ring at its own cadence.
* Producers that narrate their own progress use {@link JobHandle.append}
* instead; a job may use both.
*/
owner?: Agent
output?: readonly JobOutputSource[]
/**
* 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.
* 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 ring append and progress writers.
*/
run(): JobHooks
run(job: JobHandle): JobHooks
}
```
`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。可选的 `readOutput` 用来区分会消费输出的流式任务和仅有最终输出的任务。
```ts type-equiv
/**
* Producer face of one registered job, handed to {@link JobSpec.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 — writes log and drop instead of
* throwing, so a producer's trailing flush cannot break its own teardown path.
*/
interface JobHandle {
/** The registry-issued id (`<kind>-N`). */
readonly id: JobId
/**
* Append one chunk to the output ring. 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?: JobAppendOptions): void
/**
* Replace the live progress line (`3/10`, the current phase). Settlement
* clears it; the terminal reason travels in {@link JobOutcome.detail}.
* @param line - the new progress line.
*/
updateProgress(line: string): void
}
```
`JobHooks.done` 会在生产方释放其资源后 resolve,而不是仅在工作完成时 resolve。结果是一个值而非流的 job——subagent 的报告、workflow 渲染出的结果——把它作为 `JobOutcome.result` 返回;模型在结算后的第一次读取携带它一次。
```ts type-equiv
/** Hooks through which the runtime controls and observes producer work. */
@@ -74,12 +112,6 @@ interface JobHooks {
* registry record without claiming that the work stopped.
*/
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.
*/
readOutput?(): string
}
```
@@ -88,73 +120,207 @@ interface JobHooks {
interface JobOutcome {
/** How the job ended: finished (`completed`), cancelled (`killed`), or broke (`failed`). */
status: 'completed' | 'killed' | 'failed'
/** Kind-specific detail rendered into status lines ('exit code: 3', 'max-tokens'). */
/**
* Terminal reason rendered into status lines (`exit code: 3`, `max-tokens`).
* When the job settles `killed` after a {@link JobRegistry.kill} with a
* reason, the registry appends that reason.
*/
detail?: string
/** Final output for jobs without `readOutput`; stream jobs leave it unset. */
output?: string
/**
* Return value for jobs whose result is a value rather than a stream (a
* workflow's rendered result, a subagent's report). The output ring carries
* the stream; this is handed out once by the model's next {@link JobRegistry.read}.
*/
result?: string
}
```
拉取源把一个非消耗的偏移读取器——子进程的 `readFrom` 家族——交给注册表。注册表按自己的节奏(`dsh-jobs-local` 的 `pumpPollMs`)泵送每个源,并在结算封环之前再排干一次,因此生产方不需要把任何东西折进 `done`。
```ts type-equiv
/**
* A pull source the registry pumps into the job's output ring — the subprocess
* `readFrom` family. The registry owns the cadence and drains every source one
* last time before settlement closes the ring, so a producer folds nothing
* into its `done`.
*/
interface JobOutputSource {
/** Stream label attached to every chunk this source yields. */
channel?: JobChannel
/**
* Read everything captured since `fromByte` without consuming it.
* @param fromByte - whole-stream offset to resume from (a prior read's `nextOffset`; 0 first).
* @returns the delta text, the next offset, the lossy flag, and the spill path the source currently keeps.
*/
read(fromByte: number): JobSourceRead
}
```
```ts type-equiv
/** One incremental read from a {@link JobOutputSource}. */
interface JobSourceRead {
/** Text captured since the requested offset (the whole retained tail when lossy). */
text: string
/** Whole-stream offset to resume from on the next read. */
nextOffset: number
/** True when the requested offset slid out of the source's retained window. */
lossy: boolean
/**
* Host path of a file holding the complete stream, when the source
* currently keeps an intact one. Reported on every read, so the registry
* tracks it as source metadata: a later read without it withdraws the file.
*/
spillPath?: string
}
```
## 输出环
每个 job 拥有一个有界的环。拉取源被泵入其中,`JobHandle.append` 的推送整块落地;模型通过注册表保管的游标(`JobRegistry.read`)消耗该环,任意数量的观察者按绝对字节偏移读取它(`JobRegistry.readAt`),二者互不干扰。`JobChannel` 标记 `stdout`、`stderr` 与 `log`;`log` 是只到达观察者、从不进入模型消耗式读取的生产方叙述。结算即封流并把保留量裁剪到结算上限——环没有独立的生命周期。拉取源保留的 spill 文件是 job 元数据(`JobView.output.spillPaths`,每次泵读取都会刷新),不是逐块元数据,所以环淘汰了字节之后、甚至缺口 chunk 本身也被淘汰之后,模型的丢失输出提示仍能点名该文件。浏览器通过 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.list` 与 `job.follow` 触达名册与环,其帧列于下文的 Cordis API 一节。
```ts type-equiv
/** One chunk of a job's output ring: absolute offset, text, channel, and loss marker. */
interface JobChunk {
/** Absolute offset of the chunk's first byte; offsets never move once assigned. */
readonly at: number
/** Chunk text exactly as appended (possibly tail-trimmed by retention). */
readonly text: string
/** Stream label, when the producer supplied one. */
readonly channel?: JobChannel
/** Bytes immediately before this chunk were lost, at the producer or to retention. */
readonly gapBefore?: true
}
```
```ts type-equiv
/** Result of one non-consuming {@link JobRegistry.readAt}. */
interface JobOutputRead {
/** Retained chunks overlapping `[from, total)`, in offset order. */
chunks: readonly JobChunk[]
/**
* Offset to resume from — the ring's current `total`. 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 取消同样计入。
`JobView` 是每个读者都消费的唯一投影:模型工具、浏览器名册与观测流。`owner` 携带划定访问边界的会话 id;注册表为生命周期清理解析其背后的活体 `Agent`,从不把该对象交出去。`progress` 是生产方的实时行,结算时清除;`detail` 是终态原因,合并了记录下来的 kill 原因。
```ts type-equiv
/**
* A read-only projection of one job, safe to hand to listeners and tools —
* a fresh object per call, never live registry state.
* Read-only projection of one job — a fresh object per call, never live
* registry state. The model tools, the browser roster, and the observation
* stream all consume this one shape.
*/
interface JobSnapshot {
interface JobView {
/** The registry-issued id (`<kind>-N`). */
id: JobId
/** The producer kind the job was registered with. */
kind: JobKind
readonly id: JobId
/**
* The producer kind the job was registered with: a Host-registered
* `JobKind`, carried as an open string because a browser bundle or a Remote
* codec sees only the `JobKindMap` merges its own program compiles.
*/
readonly kind: string
/** The producer-supplied one-line label. */
label: string
/** Producer-owned cap for complete model-facing notices and output reads. */
outputLimitBytes?: number
/**
* Owner session id used for authorization and correlation; absent for
* unowned jobs. Completion listeners receive the exact {@link Agent}
* separately through {@link JobDoneListener}.
*/
ownerSession?: SessionId
readonly label: string
/** Owning session; absent for an unowned job, which every caller can see. */
readonly owner?: SessionId
/** Producer-owned cap for complete model-facing notices and reads, in UTF-8 bytes. */
readonly outputLimitBytes?: number
/** Current lifecycle state. */
status: JobStatus
/** Kind-specific status detail, present once the producer supplied one (usually terminal). */
detail?: string
readonly status: JobStatus
/** The producer's live progress line (`3/10`, the current phase); cleared at settlement. */
readonly progress?: string
/** Terminal reason (`exit code: 3`); a recorded kill reason is merged in. */
readonly detail?: string
/** Epoch ms when the job was registered. */
startedAt: number
/** Epoch ms when the job settled; absent while `running`/`stopping`. */
finishedAt?: number
readonly startedAt: number
/** Epoch ms when the job settled; absent while live. */
readonly finishedAt?: number
/**
* True when a kill, read, wait, or teardown cancel has reported or committed
* to report the terminal state. Completion reporters suppress redundant
* notices when set. Teardown claims it because the owner or service being
* destroyed leaves no reader: a reporter that opens a turn on notice would
* otherwise spend a model request per teardown layer.
* The output ring's absolute coordinates and the complete-stream files
* behind it. `total` is the offset the next chunk starts at (0 while
* nothing was written); `earliest` is the oldest retained byte, greater
* than zero exactly when retention dropped the head. `spillPaths` lists the
* spill files the job's pull sources currently keep, in source order and
* deduplicated, and is absent while no source keeps one: it outlives any
* chunk, so a reader below `earliest` can still name where the bytes went.
*/
reported: boolean
readonly output: { readonly total: number; readonly earliest: number; readonly spillPaths?: readonly string[] }
}
```
`JobRegistry` 的 `list`、`get`、`read`、`readAt`、`kill` 和 `wait` 各自接收调用方的 `SessionId`;省略调用方时仅可访问无主 job,传入会话时还可访问该会话拥有的 job。
```ts type-equiv
/** Output and post-read state returned by {@link JobRegistry.read}. */
/** Output and post-read state returned by the consuming {@link JobRegistry.read}. */
interface JobRead {
/**
* Stream kinds: the consuming delta since the previous read. Final-output
* kinds: empty while live, the terminal {@link JobOutcome.output} (or
* empty) once settled — idempotent, never consumed.
*/
text: string
/** Ring chunks appended since the model cursor, in offset order; every channel included. */
chunks: readonly JobChunk[]
/** True when the cursor fell below the oldest retained byte, so bytes are missing before `chunks`. */
lossy: boolean
/** The producer's {@link JobOutcome.result}, handed out by the first read after settlement only. */
result?: string
/** The job's state at read time. */
snapshot: JobSnapshot
job: JobView
}
```
## 事件
注册表通过一条带过滤的流宣布每次提交。生命周期事件携带其所宣布的提交之后的投影;`settled` 标出原因,完成播报方据此跳过 teardown;`output` 只携带 id 与新的 total,观察者从自己的游标读取,注册表从不推送负载。
```ts type-equiv
/**
* One lifecycle or output event. Lifecycle events carry the job's projection
* after the commit they announce; `output` carries only the id and the new
* total, so an observer schedules a {@link JobRegistry.readAt} from its own
* cursor and the registry never pushes payloads.
*/
type JobEvent =
| {
/** Registration commit, progress line change, stopping transition, or removal from the visible set. */
readonly type: 'registered' | 'progress' | 'stopping' | 'removed'
readonly job: JobView
}
| {
readonly type: 'settled'
readonly job: JobView
readonly cause: JobSettleCause
}
| {
readonly type: 'output'
readonly id: JobId
/** Owning session, absent for an unowned job. */
readonly owner?: SessionId
/** The ring's total after the append (or at settlement, which ends the stream). */
readonly total: number
}
```
```ts type-equiv
/**
* Who a subscription hears about. `{ owner }` delivers that session's jobs
* plus every unowned job (the set that session can see). `{ owners: 'scope' }`
* delivers the owners composed under the subscribing context — one registry
* serves every composition in the process, and a mount under one preset must
* not hear another preset's agents. `{ owners: 'all' }` delivers everything.
*/
type JobEventFilter =
| { readonly owner: SessionId }
| { readonly owners: 'all' | 'scope' }
```
## 服务行为
抽象的 [`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`、各自接收调用方的 `list`、`get`、消耗式 `read`、非消耗的 `readAt`、`kill` 与有界的 `wait`、带过滤的 `events` 流,以及 `attachController`;[`LocalJobRegistry`](../../packages/jobs/jobs-local/src/index.ts) 是进程本地的 Service Provider。授权比较拥有者会话;拥有者清理与准入使用 job 启动时登记在该拥有者会话下的活体 `Agent`。本地提供方的正安全整数配置 `maxConcurrentJobsPerOwner` 默认为 `10`,按精确拥有者统计 `running` 加 `stopping` 记录,无主任务共享一个桶;生产方的终态结算释放容量;`retainBytes`(默认 262144)与 `settledRetainBytes`(默认 16384)约束每个环的运行期与结算后保留量,`pumpPollMs`(默认 150)是拉取节奏。参见 [`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 -->
@@ -164,18 +330,52 @@ interface JobRead {
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="ctxjobcontroller--jobcontroller"></a>
### `ctx.jobController` — `JobController`
Host service backing the generated `ctx.remote.job` namespace.
```ts cordis-catalog
/**
* Stream the jobs one session can see — its own plus every unowned job —
* as whole-set frames: one on open, then one after each coalesced burst of
* lifecycle commits. The stream has no natural end; the carrier closes it.
* @param request - the session whose visible set to mirror.
* @param signal - cancellation owned by the Remote stream carrier.
* @returns the roster frames.
*/
@Remote({ mode: 'stream' }) list(request: JobListRequest, signal: AbortSignal): AsyncIterable<JobListFrame>
/**
* Stream one job's retained output from an absolute byte offset, then its
* terminal projection once settled and drained. Non-consuming: the
* model-facing cursor and notice state never observe these reads. The
* request's session is the fenced read's caller; the registry rejects a
* job the session cannot see and 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' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable<JobFollowFrame>
```
Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts)
<a id="ctxjobs--jobregistry-abstract-seam"></a>
### `ctx.jobs` — `JobRegistry` (abstract seam)
Abstract background job registry. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
Implementations must honor these semantics:
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Teardown cancellation also marks the record reported, because a record its owner is being destroyed for has no reader left.
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left.
- 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.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.
- 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 event delivery under `{ owners: 'scope' }` — 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.
- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other.
- Ring 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 and ends the stream; the ring has no separate lifecycle.
```ts cordis-catalog
/**
@@ -183,96 +383,73 @@ Implementations must honor these semantics:
* admission before starting and atomically registering work. Any preflight
* rejection leaves no job id or execution resource. A throwing starter
* leaves nothing registered; after it returns, registration cannot fail.
* Settlement records the outcome, notifies listeners, and releases waiters.
* @param spec - job identity, owner, and synchronous starter.
* @param spec - job identity, owner, output sources, and synchronous starter.
* @returns the registry-issued `<kind>-N` id.
*/
abstract start(spec: JobStart): JobId
abstract start(spec: JobSpec): JobId
/**
* List caller-owned and unowned jobs in registration order without exposing
* another session's labels.
* @param caller - reading agent; a non-agent caller sees only unowned jobs.
* @returns fresh snapshots.
* List caller-owned and unowned jobs in registration order.
* @param caller - reading session; omission sees only unowned jobs.
* @returns fresh projections.
*/
abstract list(caller?: Agent): JobSnapshot[]
abstract list(caller?: SessionId): JobView[]
/**
* Return a non-consuming snapshot without changing its read cursor or notice
* state. Throws for an unknown or foreign job.
* @param id - job to look up.
* @param caller - reading agent checked against the owner.
* @returns a fresh snapshot.
*/
abstract get(id: JobId, caller?: Agent): JobSnapshot
/**
* Read the next stream delta, or the idempotent final output after settlement.
* A terminal read marks the job reported. Throws for an unknown or foreign
* job.
* @param id - job to read.
* @param caller - reading agent checked against the owner.
* @returns output text and the post-read snapshot.
*/
abstract read(id: JobId, caller?: Agent): JobRead
/**
* Request cancellation, then mark the job stopping and reported. A producer
* throw propagates without changing job state. Throws for an unknown or
* Project one job without changing its cursor. Throws for an unknown or
* foreign job.
* @param id - job to look up.
* @param caller - reading session checked against the owner.
* @returns a fresh projection.
*/
abstract get(id: JobId, caller?: SessionId): JobView
/**
* Consume the ring from the model cursor and advance it to the current
* total. After settlement the first read also carries the producer's
* result. Throws for an unknown or foreign job.
* @param id - job to read.
* @param caller - reading session checked against the owner.
* @returns the chunks since the cursor, the lossy flag, the result once, and the post-read projection.
*/
abstract read(id: JobId, caller?: SessionId): JobRead
/**
* Read retained ring output without moving the model cursor. Resume with
* a previous read's `next`; an offset inside a retained chunk returns the
* whole chunk (its `at` may precede `from`). Throws for a negative or
* non-integer offset, or an unknown or foreign job.
* @param id - job to read.
* @param from - absolute byte offset to read from (0 for the retained head).
* @param caller - reading session checked against the owner.
* @returns retained chunks overlapping `[from, total)`, the resume offset, and the lossy flag.
*/
abstract readAt(id: JobId, from: number, caller?: SessionId): JobOutputRead
/**
* Request cancellation, then mark the job stopping. A producer throw
* propagates without changing job state. A supplied reason is merged into
* terminal `detail` when the job settles `killed`. Throws for an unknown
* or foreign job.
* @param id - job to cancel.
* @param caller - killing agent checked against the owner.
* @param reason - logged reason forwarded to the producer.
* @param caller - killing session checked against the owner.
* @param reason - cancellation reason forwarded verbatim to the producer.
* @returns `requested` for live work, otherwise `already-finished`.
*/
abstract kill(id: JobId, caller?: Agent, reason?: string): 'requested' | 'already-finished'
abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'already-finished'
/**
* Wait for settlement or timeout without cancelling the job. Caller abort
* rejects only while the job is live; after settlement the terminal
* snapshot wins so a notice suppressed for this waiter is still delivered.
* Throws for invalid, unknown, or foreign input.
* projection wins. Rejects for an invalid timeout or an unknown or foreign
* job.
* @param id - job to wait for.
* @param timeoutMs - positive finite wait bound in milliseconds.
* @param caller - waiting agent checked against the owner.
* @param caller - waiting session checked against the owner.
* @param signal - optional cancellation of the wait itself.
* @returns snapshot at settlement or timeout.
* @returns projection at settlement or timeout.
*/
abstract wait(id: JobId, timeoutMs: number, caller?: Agent, signal?: AbortSignal): Promise<JobSnapshot>
/**
* Register an effect-scoped completion listener. It receives the settlements
* of the owners its registering context's scope covers; each listener is
* contained; returned promises are observed but not awaited. No listener runs
* after service disposal.
* @param listener - receives each terminal snapshot and its exact owner.
* @returns disposer that unregisters the listener.
*/
abstract onJobDone(listener: JobDoneListener): () => void
/**
/**
* Register an effect-scoped observer of visible-set changes. It fires after
* every commit that changes what {@link list} returns for that owner —
* registration, every stopping transition (including the one teardown
* performs before it awaits a slow producer), settlement, owner-disposal
* removal, and the emptying that service disposal commits — so an observer
* re-reads rather than accumulating deltas.
*
* Delivery is owner-relative on the same terms as {@link onJobDone}: an
* observer 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.
*
* This is not a superset of {@link onJobDone}: that one delivers the terminal
* record under first-wins semantics a job controller couples to notice
* delivery, while this one carries no delivery meaning and marks nothing
* reported. Listeners are contained and never awaited.
* @param listener - receives the owner whose visible set changed, or
* `undefined` when an unowned job changed and every caller's set did.
* @returns disposer that unregisters the listener.
*/
abstract onJobsChanged(listener: JobsChangedListener): () => void
abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise<JobView>
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
@@ -284,7 +461,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
abstract attachController(name: string): () => void
```
Types: [Agent](core.zh.md)
Types: [SessionId](core.zh.md)
Source: [`packages/jobs/jobs/src/index.ts`](../../packages/jobs/jobs/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/session.md
session.md: 62f7785d4d1db9c30706a8b967e6f4073b810149
session.zh.md: 7cd3c38162df209d245d3f1085ab1edce002531a
session.md: be48d4833bacbac31f3765e0b4d9cdcf6da9f4d7
session.zh.md: 093fc6d570e93dc5be42d8779fc4d5965085b7e0
+11
View File
@@ -758,6 +758,8 @@ The backends that consume this contract are on [persistence.md](persistence.md).
`SessionOpenWorkspacePathRequest` carries an absolute or workspace-resolved `path`; optional `action: "reveal"` selects file-manager navigation instead of default-application opening. `SessionOpenWorkspacePathValue` confirms that the Host accepted the native handoff. A Session-aware Client resolves relative paths against its current Session cwd when known; the controller hands the path to the opener unchanged and reports invalid requests, cancellation, and opener failures through the Session Remote error vocabulary.
The optional `application` selects a registered file handler without changing the system default; `workspacePathApplications({ path })` returns current handlers, names, icons, and default selection after Host path verification.
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -845,6 +847,15 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
*/
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
/**
* Query current file handlers on the serving desktop without activating an Agent.
* @param request - file path in Host filesystem syntax.
* @param signal - caller lifetime, propagated to filesystem and desktop queries.
* @returns OS application names, icons, and default selection; empty when desktop opening is unavailable.
* @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails.
*/
@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise<readonly SessionWorkspacePathApplication[]>
/**
* Rename one Session after explicitly resuming it.
* @param request - Session identity and proposed title.
+11
View File
@@ -762,6 +762,8 @@ interface TurnEndReasonMap {
`SessionOpenWorkspacePathRequest` 携带绝对路径或已按 workspace 解析的 `path`。`SessionOpenWorkspacePathValue` 确认 Host 已接受原生交接。Session-aware Client 会在已知当前 Session cwd 时据此解析相对路径;controller 将路径原样交给打开器,并通过 Session Remote 错误词汇表报告无效请求、取消与打开器失败。 可选的 `action: "reveal"` 选择文件管理器导航;省略时使用默认应用打开。
可选的 `application` 指定文件关联应用,不修改系统默认值;`workspacePathApplications({ path })` 在校验 Host 路径后返回当前关联应用、名称、图标和默认项。
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
<a id="cordis-surface"></a>
@@ -849,6 +851,15 @@ workspaceDesktop(): { name: string; available: boolean; fileManager: 'finder' |
*/
@Remote('openWorkspacePath') async openWorkspacePath( request: SessionOpenWorkspacePathRequest, signal: AbortSignal, ): Promise<SessionOpenWorkspacePathValue>
/**
* Query current file handlers on the serving desktop without activating an Agent.
* @param request - file path in Host filesystem syntax.
* @param signal - caller lifetime, propagated to filesystem and desktop queries.
* @returns OS application names, icons, and default selection; empty when desktop opening is unavailable.
* @throws RemoteError when the path is invalid, the query is cancelled, or native discovery fails.
*/
@Remote('workspacePathApplications') async workspacePathApplications( request: { readonly path: string }, signal: AbortSignal, ): Promise<readonly SessionWorkspacePathApplication[]>
/**
* Rename one Session after explicitly resuming it.
* @param request - Session identity and proposed title.
+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/shell.md
shell.md: de04083093a1e53aedd310f388f778638d858ce7
shell.zh.md: d68b1b2cfba3214a23e9120f19fc9a029a181a18
shell.md: 734b9e7c72948be23d2c6a2c82c6af27c1e2812b
shell.zh.md: bfe455c2e5f680f743188e8accb70008a95ab8f9
+7
View File
@@ -195,6 +195,13 @@ interface ShellProcess {
* full-stream spill files when available.
*/
readOutput(): ShellProcessRead
/**
* Non-consuming offset readers over the same captured streams the consuming
* {@link readOutput} cursor drains, including the provider-failure note a
* rejected spawn leaves on stderr. Independent observers read here at their
* own offsets without stealing bytes from `readOutput`.
*/
observed: ShellObservedStreams
/**
* Terminate the provider-managed range. Returns false when it had already finished
* (no-op); idempotent.
+7
View File
@@ -195,6 +195,13 @@ interface ShellProcess {
* full-stream spill files when available.
*/
readOutput(): ShellProcessRead
/**
* Non-consuming offset readers over the same captured streams the consuming
* {@link readOutput} cursor drains, including the provider-failure note a
* rejected spawn leaves on stderr. Independent observers read here at their
* own offsets without stealing bytes from `readOutput`.
*/
observed: ShellObservedStreams
/**
* Terminate the provider-managed range. Returns false when it had already finished
* (no-op); idempotent.
+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/slots.md
slots.md: 1e820fd880163d565a72733372fa4f2cc70a62b5
slots.zh.md: c5bec8e752b9d9e1922f608449ca3249d0477062
slots.md: 3a2eb7d0ff7297f6c80fa083134144cbb7a6864e
slots.zh.md: 16a529aa46837af0aaa675271e765bd88f29e623
+2
View File
@@ -6,6 +6,8 @@ Slots are the Web Client's typed React composition system. [`dsh-client-ui-slots
This page documents slot ownership, component inputs, extension APIs, and the shipped hierarchy. The surrounding boot, Remote, Client model, and Conversation paths are in [Web Client architecture](web-client.md).
`plugins.bundle.config` supplies bundle detail configuration, keyed by npm package name. `plugins.bundle.activation` renders optional guidance after user-requested enablement, with owner callbacks to dismiss or open that bundle’s details. `conversation.input.activity` supplies one action between the model selector and Send, with toolbar expansion released on unmount.
## Declaration and lifecycle
`SlotMap` is the compile-time registry. A package declaration-merges the key, cardinality, scope, owner props, keyed props, and optional slot-level inject face. The runtime declaration is the matching `children` entry on the component that owns the render location.
+2
View File
@@ -6,6 +6,8 @@ Slots 是 Web Client 的类型化 React 组合系统。[`dsh-client-ui-slots`](.
本文记录 slot 的所有权、组件输入、扩展 API 与当前层级。外围的启动、Remote、Client model 与 Conversation 数据通路见 [Web Client 架构](web-client.zh.md)。
`plugins.bundle.config` 以 npm 包名为 key,提供 Bundle 详情配置。`plugins.bundle.activation` 在用户显式启用后渲染可选引导,由拥有者提供关闭引导和打开该 Bundle 详情的回调。`conversation.input.activity` 提供模型选择器与发送按钮之间的单个活动控件,可展开工具栏并在卸载时释放展开状态。
## 声明与生命周期
`SlotMap` 是编译期注册表。包通过声明合并写入 key、cardinality(基数)、scope、owner props、keyed props 与可选的 slot 级 inject face。运行时声明则是拥有该渲染位置的组件在 `children` 中给出的对应条目。
+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/web-client.md
web-client.md: 6bcf9bf84ed0b049b801c38d2426074d784e8de6
web-client.zh.md: 9c697964a0f92bc646f5ca305fc3372bebcdc902
web-client.md: c43b7553d1a223b82fec73c7431e4655cf1cbee1
web-client.zh.md: d98febb7494a2b2fe082f8544850dbf67ee75794
+4 -3
View File
@@ -40,10 +40,10 @@ Each API controller package owns a paired Host and Client face. The Host side ow
[`api/session-controller`](../../packages/api/session-controller/README.md) exposes Host commands for list, search, creation, prompt, queue, cancellation, pagination, and follow/control streams. Its Client side is organized as `ClientSessions → SessionManager → Session`:
- `ClientSessions` provides `ctx.sessions`, owns references, source counts, Session scopes, and stable `SessionBinding` objects, and projects catalog state without selecting a global current Session.
- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, queues, projection stores, subagent catalogs, and conflict ordering between pulls and later updates.
- `SessionManager` owns the list baseline, live list/control updates, lazy Session instances, projection stores, subagent catalogs, and conflict ordering between pulls and later updates.
- Each `Session` owns one contiguous logical-event window represented by `SessionEventLikeEntry` values, paging, follow, prompt/control state, and the observable snapshot consumed by adapters.
The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. History records have an explicit `event` or `chunks` discriminator and an aligned inner `event`; the journal validates each inclusive logical sequence range before the Client retains the records as `SessionEventLikeEntry` values without per-record conversion. Each physical generation atomically replaces the retained window from that snapshot; standard live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies queue, job, and projection updates.
The durable event path opens `follow()`, whose first frame contains the current header, tail page, cursor, and complete projection baseline. History records have an explicit `event` or `chunks` discriminator and an aligned inner `event`; the journal validates each inclusive logical sequence range before the Client retains the records as `SessionEventLikeEntry` values without per-record conversion. Each physical generation atomically replaces the retained window from that snapshot; standard live events then append by sequence. `page()` is reserved for older history and gap repair. The transient control stream starts every generation with a complete baseline and then applies projection updates.
### Workspaces
@@ -66,7 +66,8 @@ Web and desktop share the [developer-tool preference](../../packages/client/ui-s
| Path | Sequence |
|---|---|
| durable Session display | Host Session log → packed Remote `follow`/`page` history → Client `SessionEventLikeEntry` window → Conversation Contexts → target snapshot (`chat`, `trajectory`, or another registered target) → Slot view → React |
| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection stores → Session and list snapshots → standard hooks → components |
| transient Session control | Host control baseline → Remote snapshot stream → `SessionManager` projection stores → Session and list snapshots → standard hooks → components |
| Background jobs | Host job registry → `job.list` / `job.follow` → [`ClientJobs`](../../packages/api/job-controller/README.md) roster and output views → job list and panels |
| Workspace state | Host Workspace baseline and increments → `ClientWorkspaceModel` → `ctx.workspaces.list` → `useWorkspaces` → sidebar, hero, and navigation entries |
| scoped interaction | Host Cordis waterfall → API Remotes `$events` → `ctx.remote.$on()` on the Session Context → owning UI package → result or `next()` |
| user command | component callback → registration inject face or Slot owner → `ctx.sessions`, `ctx.workspaces`, or generated scoped Remote → Host Controller → authoritative update → stream or event projection back to the Client |
+4 -3
View File
@@ -40,10 +40,10 @@ Connection 拥有 request correlation、`/api` carrier、trust check、精确 Fe
[`api/session-controller`](../../packages/api/session-controller/README.zh.md)公开 Session list、search、creation、prompt、queue、cancellation、pagination 及 follow/control stream 等 Host command。其 Client 侧按 `ClientSessions → SessionManager → Session` 组织:
- `ClientSessions` 提供 `ctx.sessions`,拥有 reference、source count、Session scope 与稳定的 `SessionBinding` object,并投影不含全局 current Session 选择的 catalog state。
- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、queue、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。
- `SessionManager` 拥有 list baseline、实时 list/control update、惰性 Session instance、projection store、subagent catalog,以及 pull 与后到 update 之间的冲突顺序。
- 每个 `Session` 拥有一段由 `SessionEventLikeEntry` value 表示的连续逻辑 event window、pagination、follow、prompt/control state 与供 adapter 消费的 observable snapshot。
持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。历史 record 带有显式 `event` 或 `chunks` 判别字段和字段对齐的内部 `event`;journal 先校验每条 record 的逻辑 seq 闭区间,Client 再直接把这些 record 保留为 `SessionEventLikeEntry`,无需逐 record 转换。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 标准实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 queue、job 与 projection update。
持久 event 路径打开 `follow()`,其首帧包含当前 header、tail page、cursor 与完整 projection baseline。历史 record 带有显式 `event` 或 `chunks` 判别字段和字段对齐的内部 `event`;journal 先校验每条 record 的逻辑 seq 闭区间,Client 再直接把这些 record 保留为 `SessionEventLikeEntry`,无需逐 record 转换。每个物理 generation 都根据该 snapshot 原子替换保留窗口,随后按 seq append 标准实时 event。`page()` 只用于更早历史与 gap repair。瞬态 control stream 每代以完整 baseline 开始,随后应用 projection update。
### Workspaces
@@ -66,7 +66,8 @@ Web 和桌面端共享[开发者工具偏好](../../packages/client/ui-settings/
| 路径 | 顺序 |
|---|---|
| 持久 Session 展示 | Host Session log → packed Remote `follow`/`page` 历史 → Client `SessionEventLikeEntry` window → Conversation Context → target snapshot(`chat`、`trajectory` 或其他已注册 target)→ Slot view → React |
| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` queue/job/projection store → Session 与 list snapshot → 标准 hook → component |
| 瞬态 Session control | Host control baseline → Remote snapshot stream → `SessionManager` projection store → Session 与 list snapshot → 标准 hook → component |
| 后台任务 | Host job 注册表 → `job.list` / `job.follow` → [`ClientJobs`](../../packages/api/job-controller/README.zh.md) 名册与输出视图 → 任务列表和面板 |
| Workspace 状态 | Host Workspace baseline 与 increment → `ClientWorkspaceModel` → `ctx.workspaces.list` → `useWorkspaces` → sidebar、hero 与 navigation entry |
| scoped interaction | Host Cordis waterfall → API Remotes `$events` → Session Context 上的 `ctx.remote.$on()` → 所属 UI 包 → result 或 `next()` |
| 用户 command | component callback → 注册项 inject face 或 Slot owner → `ctx.sessions`、`ctx.workspaces` 或生成的 scoped Remote → Host Controller → 权威 update → stream 或 event projection 回到 Client |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/README.md
README.md: 79271f6f5f7a05ef4f6d5da63815dfe1ee5da3ef
README.zh.md: d98a0ef73a1b14a5eea98084cfb4ef224a7747a9
README.md: 691a56856d0fceb62d3b32c3d5af90dd0323983a
README.zh.md: 9303fbaae7b0f9a10880c18a7af79f1dffb3bdeb
+1
View File
@@ -28,6 +28,7 @@ The packages below provide the Remote layer; the package READMEs own the exhaust
|---|---|---|
| [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — |
| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams with their Client uplinks, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` |
| [`job-controller/`](job-controller/README.md) | Streams one background job's observation record to the Client. | `ctx.jobController` / `ctx.remote.job` |
| [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` |
| [`settings-controller/`](settings-controller/README.md) | Owns the configuration-surface reads and writes over the settings-domain seams. | `ctx.settingsController`, `ctx.credentialsController` / `ctx.remote.settings`, `ctx.remote.credentials` |
| [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` |
+1
View File
@@ -28,6 +28,7 @@ kind: "package-group"
|---|---|---|
| [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — |
| [`gateway/`](gateway/README.zh.md) | 承载类型化一元调用、带 Client 上行的多路复用流,以及转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` |
| [`job-controller/`](job-controller/README.zh.md) | 把一个后台任务的观测 record 流式送到 Client。 | `ctx.jobController` / `ctx.remote.job` |
| [`session-controller/`](session-controller/README.zh.md) | 拥有会话命令、历史记录流、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` |
| [`settings-controller/`](settings-controller/README.zh.md) | 拥有 settings 域各 seam 之上的配置界面读写。 | `ctx.settingsController`、`ctx.credentialsController` / `ctx.remote.settings`、`ctx.remote.credentials` |
| [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` |
@@ -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 packages/api/job-controller/README.md
README.md: d3698758cc144c033037759a834944870948284d
README.zh.md: d9744b4d19cfff6d73923d0d26e3e817bc54c1a3
+67
View File
@@ -0,0 +1,67 @@
---
description: "Host and Client job streams: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor."
kind: "package-reference"
---
# Job Controller
English | [中文](README.zh.md)
## Summary
`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.list` mirrors the jobs one session can see as whole-set frames, and `job.follow` delivers one job's retained output from an absolute byte offset. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders. Neither stream touches the model's consuming cursor or the completion-notice ledger.
## Table of Contents
- [Use this package](#use-this-package)
- [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
The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.list({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.follow({ sessionId?, jobId, from? })` passes `sessionId` to registry `get` and `readAt` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them.
The Client entry installs `ctx.jobs` (`IJobs`), backed by the package-internal `ClientJobsModel`. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`.
### Config
| Field | Default | Meaning |
|---|---:|---|
| `observeFlushMs` | `100` | Coalescing window after a registry commit before the next rows or output read, in milliseconds |
| `observeMaxFrameBytes` | `65,536` | Soft byte budget per observation output frame; one larger chunk ships whole |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-job-controller) is the exhaustive source for accepted fields and their JSDoc.
-----
<a id="model-experience"></a>
## Model Experience
None, as job observation is browser and Host control state; it registers no prompt, tool, or session event. The model's own view of the same work stays with [`dsh-tool-jobs`](../../jobs/tool-jobs/README.md).
#### KV Cache effect
No direct effect; observation reads never touch model requests.
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- The ring is a best-effort live preview, not a terminal transcript: producers' pull sources are copied per poll round, so two streams' writes inside one poll window land stdout first, and the client concatenates chunks regardless of `channel`.
- Both streams are process-local: a Host restart loses every ring and roster; a resumed observation then anchors on an empty registry, and a re-opened roster starts empty.
- Per-session fencing is enforced by the caller argument of each registry read; the Remote layer itself serves any connected browser.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
**Runtime invariant:** No companion is published. The controller is a stateless projection of `ctx.jobs` reads; the registry's own `@deepseek-ai/dsh-jobs/invariant` owns the event protocol and event-versus-read relations these streams forward.
+67
View File
@@ -0,0 +1,67 @@
---
description: "Host 与 Client 的 job 流:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标。"
kind: "package-reference"
---
# Job Controller
[English](README.md) | 中文
## 概述
`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.list` 以整集帧镜像一个会话看得到的 job;`job.follow` 从绝对字节偏移发送一个 job 的保留输出。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图。两条流都不触碰模型的消耗型游标与完成播报台账。
## 目录
- [使用本包](#use-this-package)
- [模型体验](#model-experience)
- [已知限制与延期工作](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.list({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.follow({ sessionId?, jobId, from? })` 把 `sessionId` 传给注册表的 `get` 与 `readAt`——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。
Client 入口安装 `ctx.jobs`(`IJobs`),由包内部的 `ClientJobsModel` 支撑。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。
### 配置
| 字段 | 默认值 | 含义 |
|---|---:|---|
| `observeFlushMs` | `100` | 注册表提交到下一次名册或输出读取之间的聚合窗口,毫秒 |
| `observeMaxFrameBytes` | `65,536` | 每个观测输出帧的软字节预算;更大的单块整块发送 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-job-controller)是所有受支持字段及其 JSDoc 的完整来源。
-----
<a id="model-experience"></a>
## 模型体验
无。job 观测是浏览器与 Host 的控制状态;它不注册任何提示词、工具或会话事件。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。
#### KV 缓存影响
无直接影响;观测读取从不触碰模型请求。
## 已知限制与延期工作
<a id="known-limitations-and-deferred-work"></a>
- 环是尽力而为的实时预览,不是终端转录:生产者的拉取源按轮询轮次复制,同一轮询窗口内两条流的写入先 stdout 落地,客户端拼接 chunk 时也不区分 `channel`。
- 两条流都是进程本地的:Host 重启丢失全部环与名册,续读的观测随后锚定在空注册表上,重开的名册从空开始。
- 按 session 的访问限制由注册表每次读取时的调用方参数强制;Remote 层本身服务任何已连接浏览器。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者的工作上下文——点击展开</summary>
无。
</details>
**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;这些流转发的事件协议与事件对读取的关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。
+86
View File
@@ -0,0 +1,86 @@
{
"name": "@deepseek-ai/dsh-api-job-controller",
"description": "Job Remote observation stream and the reference-counted client job-output service",
"version": "0.1.6-alpha.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/api/job-controller"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.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"
},
"dsh": {
"client": {
"external": [
"@deepseek-ai/dsh-api-gateway/client"
],
"inject": [
"@deepseek-ai/dsh-api-gateway",
"@deepseek-ai/dsh-client-connection"
],
"platform": "web"
}
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"files": [
"lib/index.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/dsh-typert-protocol": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"zod": "^4.4.3"
},
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis": "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-jobs": "workspace:^",
"@deepseek-ai/dsh-jobs-local": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-typert-registry": "workspace:^"
}
}
Loaded 100 of 397 files, more files were not shown because too many files have changed in this diff. Show more