diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml
index 87e45ad40a..3a332e7121 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml
+++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md
-2026-09-01-jobs-absorb-activity-record.md: cc2cc8606a50e2d7c06e183a09df709b75b9bd35
-2026-09-01-jobs-absorb-activity-record.zh.md: 8b6739192b56899899773ba1e7af2bf1bd846b0b
+2026-09-01-jobs-absorb-activity-record.md: aa147778d06133b61f79defae6f9f6807dd97f41
+2026-09-01-jobs-absorb-activity-record.zh.md: 12e8f4bbd909a480f1461433c58d7937a8d7c857
diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md
index cc2cc8606a..aa147778d0 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.md
@@ -18,7 +18,7 @@ The one activity without a job was the foreground workflow mirror. Everything el
- **There is no `end`.** Job settlement — the producer outcome, a kill, or teardown — is the record's only close: it trims retention to the settled cap and fires the final `onOutput` signal. The dual-settlement pairing (`ActivityHandle.end` first-wins against teardown force-ends) is deleted, not reimplemented.
- **`readRecord(id, from, caller)`** is the non-consuming multi-reader view (absolute UTF-8 offsets, `lossy` below the retained window), fenced like every other job read; it never marks the job `reported`. The model-facing `readOutput` cursor is untouched — the two projections serve different readers and stay separate by design.
- **`jobs-local`** absorbs the chunk ring (`retainBytes` 256 KiB live, `settledRetainBytes` 16 KiB settled), and `pumpJobOutput` replaces `pumpActivityOutput` beside the seam. Producers fold the pump's final drain into `hooks.done` so the record holds its last bytes before settlement closes it.
-- **The wire moves to the session namespace.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable; `session.observeJob({sessionId?, jobId, from?})` streams anchor/output/status frames with the fenced read resolved from the request's session — the killJob authorization shape. `api-activity-controller` is deleted; its roster stream is redundant (the jobs frames are the roster) and its observe machinery moved into `session-controller` (`observe-job.ts`, client `ctx.jobOutput`).
+- **The wire splits by role.** `SessionJob.outputTotal` (present exactly for record jobs) marks a row observable on the session control stream; `job.observe({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 `outputTotal` is present. The two-roster join is deleted.
- **Foreground workflow loses its live panel deliberately.** `tool-workflow`'s activity mirror is removed; a foreground run surfaces through its recorded run/member lifecycle events only, and the per-line `workflow/phase` / `workflow/log` narration has no observer until workflow gains `run_in_background` and registers a record job. Jobs stay a pure background registry — no `foreground` mode bit, no model-invisible rows, no run-less rows.
@@ -32,4 +32,4 @@ The [prior seam note](../feature/2026-08-24-activity-observation-seam.md)'s argu
## Consequences
-Every observable row is a killable job; a pure-observation surface with no lifecycle would need a new home (harness diagnostics belong to the inspector plane, not here). A future remote job provider must implement lifecycle and record together. The record's retention config lives on `jobs-local`; the observation wire's cadence config (`observeFlushMs`, `observeMaxFrameBytes`) lives on `session-controller`.
+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`.
diff --git a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md
index 8b6739192b..12e8f4bbd9 100644
--- a/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md
+++ b/.agents/notes/implemented/architecture/2026-09-01-jobs-absorb-activity-record.zh.md
@@ -18,7 +18,7 @@ Status: implemented
- **没有 `end`。** job 结算——生产者 outcome、kill 或 teardown——是 record 唯一的关闭:裁剪到结算保留量并发出最后一个 `onOutput` 信号。双结算配对(`ActivityHandle.end` 与 teardown 强结的 first-wins 对账)被删除,而非重新实现。
- **`readRecord(id, from, caller)`** 是非消耗多读者视图(绝对 UTF-8 偏移,低于保留窗口为 `lossy`),围栏与其他 job 读取一致;永不置 `reported`。模型面的 `readOutput` 游标原样保留——两个投影服务不同读者,刻意保持分离。
- **`jobs-local`** 吸收块环(`retainBytes` 运行期 256 KiB,`settledRetainBytes` 结算后 16 KiB),`pumpJobOutput` 在 seam 旁替换 `pumpActivityOutput`。生产者把 pump 的末次排空折进 `hooks.done`,让 record 在结算关闭前握有最后的字节。
-- **wire 移入 session 命名空间。** `SessionJob.outputTotal`(恰在 record job 上存在)标记行可观测;`session.observeJob({sessionId?, jobId, from?})` 流式发送 anchor/output/status 帧,围栏读取者由请求的 session 解析——与 killJob 相同的授权形态。`api-activity-controller` 删除;其名册流是冗余的(jobs 帧就是名册),observe 机制移入 `session-controller`(`observe-job.ts`,客户端 `ctx.jobOutput`)。
+- **wire 按角色拆分。** `SessionJob.outputTotal`(恰在 record job 上存在)在会话控制流上标记行可观测;`api-job-controller` 的 `job` 命名空间上的 `job.observe({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` 行,恰在 `outputTotal` 存在时可展开。双名册 join 被删除。
- **前台 workflow 有意失去实时面板。** `tool-workflow` 的 activity 镜像被移除;前台 run 只通过已记录的 run/member 生命周期事件呈现,逐行的 `workflow/phase` / `workflow/log` 叙述在 workflow 获得 `run_in_background` 并登记 record job 之前没有观察者。jobs 保持纯后台注册表——没有 `foreground` 模式位、没有模型不可见行、没有无 run 行。
@@ -32,4 +32,4 @@ Status: implemented
## 后果
-每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `session-controller`。
+每个可观测行都是可 kill 的 job;没有生命周期的纯观测面需要新的归属(harness 诊断属于 inspector 平面,不在这里)。未来的远端 job provider 必须同时实现生命周期与 record。record 的保留配置在 `jobs-local`;观测 wire 的节奏配置(`observeFlushMs`、`observeMaxFrameBytes`)在 `api-job-controller`。
diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml
index 0f57f09f95..902b8a63bf 100644
--- a/docs/capability-seams.i18n.yaml
+++ b/docs/capability-seams.i18n.yaml
@@ -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: 635ef1defb7acabf78d94b28c6bef66a43f518b3
-capability-seams.zh.md: 21619c9d20f66edee269a1599ced6f4d4f55df38
+capability-seams.md: 30b3a2f8fdbb66410ada6ab27551d6018a1ac8eb
+capability-seams.zh.md: 834131588213627cefd4a6ea9c69d67f5d345ba7
diff --git a/docs/capability-seams.md b/docs/capability-seams.md
index 635ef1defb..30b3a2f8fd 100644
--- a/docs/capability-seams.md
+++ b/docs/capability-seams.md
@@ -39,6 +39,8 @@ flowchart LR
svc_sessionController["ctx.sessionController
Host Session Remote controller"]
svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"]
svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"]
+ pkg_api_job_controller["api-job-controller"]
+ svc_jobController["ctx.jobController
Host job Remote controller"]
pkg_api_settings_controller["api-settings-controller"]
svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"]
svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"]
@@ -223,6 +225,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
@@ -369,6 +372,7 @@ 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
@@ -475,6 +479,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.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | Owns Workspace commands and reconnect-safe Workspace state delivery through the generated Remote namespace. |
@@ -524,7 +529,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-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
+| `ctx.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). |
diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md
index 21619c9d20..8341315882 100644
--- a/docs/capability-seams.zh.md
+++ b/docs/capability-seams.zh.md
@@ -41,6 +41,8 @@ flowchart LR
svc_sessionController["ctx.sessionController
Host Session Remote controller"]
svc_sessionFileReferences["ctx.sessionFileReferences
Session-addressed file-reference Remote adapter"]
svc_sessionSkillCatalog["ctx.sessionSkillCatalog
Session-addressed skill Remote adapter"]
+ pkg_api_job_controller["api-job-controller"]
+ svc_jobController["ctx.jobController
Host job Remote controller"]
pkg_api_settings_controller["api-settings-controller"]
svc_credentialsController["ctx.credentialsController
Host credential-surface Remote controller"]
svc_settingsController["ctx.settingsController
Host settings-surface Remote controller"]
@@ -225,6 +227,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
@@ -371,6 +374,7 @@ 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
@@ -477,6 +481,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.workspaceController` | `core` | [`api-workspace-controller`](../packages/api/workspace-controller) | - | - | - | 通过生成的 Remote namespace 负责 Workspace 命令和可在重连后收敛的 Workspace 状态投递。 |
@@ -526,7 +531,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-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
+| `ctx.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(不通过协议发布)。 |
diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml
index c065e16e00..102b6810cd 100644
--- a/docs/config-catalog.i18n.yaml
+++ b/docs/config-catalog.i18n.yaml
@@ -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: 2c8043732edb0d47e5eaffd02f95e4293060a4dd
-config-catalog.zh.md: 24c077ca51e64a4f6235170726a909be9e0e5207
+config-catalog.md: 36eb0f1e9385b9ddc4a09a9476d3b483075282ba
+config-catalog.zh.md: 90e94c3e739dd854c1a4243297d1e5aef557f828
diff --git a/docs/config-catalog.md b/docs/config-catalog.md
index 2c8043732e..36eb0f1e93 100644
--- a/docs/config-catalog.md
+++ b/docs/config-catalog.md
@@ -199,6 +199,24 @@ export interface Config {
Source: [`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts)
+
+
+## `@deepseek-ai/dsh-api-job-controller`
+
+Requires: `agents` · `jobs` · `typert`
+
+```ts config-catalog
+/** Job Controller deployment policy. */
+export interface Config {
+ /** Coalescing window after new record output before an observation 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)
+
## `@deepseek-ai/dsh-api-session-controller`
@@ -214,14 +232,10 @@ export interface Config {
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
- /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */
- readonly observeFlushMs?: number
- /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */
- readonly observeMaxFrameBytes?: number
}
```
-Source: [`packages/api/session-controller/src/index.ts:82`](../packages/api/session-controller/src/index.ts)
+Source: [`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts)
diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md
index 24c077ca51..90e94c3e73 100644
--- a/docs/config-catalog.zh.md
+++ b/docs/config-catalog.zh.md
@@ -201,6 +201,26 @@ export interface Config {
来源:[`packages/api/gateway/src/index.ts:119`](../packages/api/gateway/src/index.ts)
+
+
+## `@deepseek-ai/dsh-api-job-controller`
+
+需要:`agents` · `jobs` · `typert`
+
+```ts config-catalog
+/** Job Controller deployment policy. */
+export interface Config {
+ /** Coalescing window after new record output before an observation 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)
+
+
+
## `@deepseek-ai/dsh-api-session-controller`
@@ -216,14 +236,10 @@ export interface Config {
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
- /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */
- readonly observeFlushMs?: number
- /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */
- readonly observeMaxFrameBytes?: number
}
```
-来源:[`packages/api/session-controller/src/index.ts:82`](../packages/api/session-controller/src/index.ts)
+来源:[`packages/api/session-controller/src/index.ts:72`](../packages/api/session-controller/src/index.ts)
diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml
index 1efb0f757c..8a378b23f1 100644
--- a/docs/event-producer-consumer.i18n.yaml
+++ b/docs/event-producer-consumer.i18n.yaml
@@ -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: 26096b99ef32702b85a622fa0342f215bb90ddf9
-event-producer-consumer.zh.md: 3e224d7a192e1c8c361fe2c7ad88b2d817bfbb1e
+event-producer-consumer.md: 930ffba24789ee51db110310cb17a2231135cf1d
+event-producer-consumer.zh.md: 6583b1d9f333ad934db2123a2bc5c40b12f1ae36
diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md
index 26096b99ef..930ffba247 100644
--- a/docs/event-producer-consumer.md
+++ b/docs/event-producer-consumer.md
@@ -21,11 +21,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../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:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md
index 3e224d7a19..6583b1d9f3 100644
--- a/docs/event-producer-consumer.zh.md
+++ b/docs/event-producer-consumer.zh.md
@@ -23,11 +23,11 @@
| `agent/session-start` | `emit` | [`packages/core/agent/src/runtime-types.ts:224`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | `agent-team`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:185`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`agent`](../packages/core/agent), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`schedule`](../packages/schedule/schedule), `server`, `session-controller` |
| `agent/turn-stopping` | `serial` | [`packages/core/agent/src/runtime-types.ts:285`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex) |
-| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:605`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:585`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:612`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:591`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
-| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:598`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/activity` | `emit` | [`packages/api/session-controller/src/types.ts:550`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/added` | `emit` | [`packages/api/session-controller/src/types.ts:530`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/error` | `emit` | [`packages/api/session-controller/src/types.ts:557`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/removed` | `emit` | [`packages/api/session-controller/src/types.ts:536`](../packages/api/session-controller/src/types.ts) | `session-controller` (`emit`) | `remotes` |
+| `api-session/status` | `emit` | [`packages/api/session-controller/src/types.ts:543`](../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:81`](../packages/interaction/commands/src/types.ts) | [`commands`](../packages/interaction/commands) (`events.dispatch`) | `remotes` |
diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml
index 7a1a91a45a..64db381cab 100644
--- a/docs/module-graph.i18n.yaml
+++ b/docs/module-graph.i18n.yaml
@@ -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: 514d09c5883ec8196e0706fbd7a3ccf45b8aafb5
-module-graph.zh.md: 444100e17ce6794efa39ef2e0a10872a6e1c3484
+module-graph.md: e280a648b8550d34b129f05971ebd0547690f117
+module-graph.zh.md: b068b7216481fb86873c290d31e6d76ce85e3fbd
diff --git a/docs/module-graph.md b/docs/module-graph.md
index 514d09c588..e280a648b8 100644
--- a/docs/module-graph.md
+++ b/docs/module-graph.md
@@ -108,6 +108,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"]
@@ -1153,6 +1154,7 @@ flowchart TD
| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
| [`llm`](../packages/llm/llm) | `llm` | — |
| [`api-gateway`](../packages/api/gateway) | `api` | — |
+| [`api-job-controller`](../packages/api/job-controller) | `api` | — |
| [`cmdline`](../packages/boot/cmdline) | `boot` | — |
| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
| [`base`](../packages/bundle/base) | `bundle` | — |
diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md
index 444100e17c..b068b72164 100644
--- a/docs/module-graph.zh.md
+++ b/docs/module-graph.zh.md
@@ -110,6 +110,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"]
@@ -1155,6 +1156,7 @@ flowchart TD
| [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions) | `llm` | — |
| [`llm`](../packages/llm/llm) | `llm` | — |
| [`api-gateway`](../packages/api/gateway) | `api` | — |
+| [`api-job-controller`](../packages/api/job-controller) | `api` | — |
| [`cmdline`](../packages/boot/cmdline) | `boot` | — |
| [`acp-app`](../packages/bundle/acp-app) | `bundle` | — |
| [`base`](../packages/bundle/base) | `bundle` | — |
diff --git a/docs/subsystems/jobs.i18n.yaml b/docs/subsystems/jobs.i18n.yaml
index 370043fe58..022befcbd2 100644
--- a/docs/subsystems/jobs.i18n.yaml
+++ b/docs/subsystems/jobs.i18n.yaml
@@ -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: 1b8e58f27be9e7198063960d436e61502c4f7e3b
-jobs.zh.md: 0a1d04fcaf0ea0ceadcb457c756f0962e47eca34
+jobs.md: 0610d99892da22a25590ab7efe8ff71d9a004f01
+jobs.zh.md: 4c35fd7c879ddf7133c0db55df24948b40118b9d
diff --git a/docs/subsystems/jobs.md b/docs/subsystems/jobs.md
index 1b8e58f27b..0610d99892 100644
--- a/docs/subsystems/jobs.md
+++ b/docs/subsystems/jobs.md
@@ -113,7 +113,7 @@ interface JobOutcome {
## Observation record
-A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence.
+A producer that declares `record: true` streams raw output into a bounded per-job ring through the `RunningJob` face its starter receives; any number of observers read retained chunks at absolute byte offsets through `readRecord` without touching the consuming model cursor or notice state. Job settlement ends the stream and trims retention to the settled cap — the record has no separate lifecycle. `pumpJobOutput` copies producer offset-readers (the subprocess `readFrom` family) into the record at a bounded cadence. Browsers reach the record through `job.observe`, the Remote stream of [`dsh-api-job-controller`](../../packages/api/job-controller/README.md), whose frames are listed under its Cordis API section below.
```ts type-equiv
/**
@@ -258,6 +258,28 @@ 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).
+
+
+### `ctx.jobController` — `JobController`
+
+Host service backing the generated `ctx.remote.job` namespace.
+
+```ts cordis-catalog
+/**
+ * Stream one job's retained record output from an absolute byte offset,
+ * then its terminal status once settled and drained. Non-consuming: the
+ * model-facing cursor and notice state never observe these reads. The
+ * request's session resolves the fenced-read caller; the registry rejects a
+ * job the session does not own, a job without a record, or an unknown job.
+ * @param request - target job, owning session, and optional resume offset.
+ * @param signal - cancellation owned by the Remote stream carrier.
+ * @returns anchor, coalesced output frames, and the terminal status.
+ */
+@Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable
+```
+
+Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts)
+
### `ctx.jobs` — `JobRegistry` (abstract seam)
diff --git a/docs/subsystems/jobs.zh.md b/docs/subsystems/jobs.zh.md
index 0a1d04fcaf..4c35fd7c87 100644
--- a/docs/subsystems/jobs.zh.md
+++ b/docs/subsystems/jobs.zh.md
@@ -113,7 +113,7 @@ interface JobOutcome {
## 观测 record
-声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。
+声明 `record: true` 的生产方通过 starter 收到的 `RunningJob` 面把原始输出流入按 job 划分的有界环形缓冲;任意数量的观察者通过 `readRecord` 按绝对字节偏移读取保留块,不触碰消耗型的模型游标与通知状态。job 结算即封流并把保留量裁剪到结算上限——record 没有独立生命周期。`pumpJobOutput` 以有界节奏把生产方的偏移读取器(subprocess 的 `readFrom` 家族)复制进 record。浏览器经 [`dsh-api-job-controller`](../../packages/api/job-controller/README.zh.md) 的 Remote 流 `job.observe` 读取 record,其帧类型列在下方该控制器的 Cordis API 一节。
```ts type-equiv
/**
@@ -258,6 +258,28 @@ 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).
+
+
+### `ctx.jobController` — `JobController`
+
+Host service backing the generated `ctx.remote.job` namespace.
+
+```ts cordis-catalog
+/**
+ * Stream one job's retained record output from an absolute byte offset,
+ * then its terminal status once settled and drained. Non-consuming: the
+ * model-facing cursor and notice state never observe these reads. The
+ * request's session resolves the fenced-read caller; the registry rejects a
+ * job the session does not own, a job without a record, or an unknown job.
+ * @param request - target job, owning session, and optional resume offset.
+ * @param signal - cancellation owned by the Remote stream carrier.
+ * @returns anchor, coalesced output frames, and the terminal status.
+ */
+@Remote({ mode: 'stream' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable
+```
+
+Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts)
+
### `ctx.jobs` — `JobRegistry` (abstract seam)
diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml
index 3a2bd2ec51..f9653cecea 100644
--- a/docs/subsystems/session.i18n.yaml
+++ b/docs/subsystems/session.i18n.yaml
@@ -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: 36c15ffd5244da732dea003259d476c0d1812d89
-session.zh.md: b31fd6593cb9d72c6577ee7a94c02d6dd04994e4
+session.md: 48a80ccb92af774db2f5bedea8e2e00043932d31
+session.zh.md: df5cfd631a15bfe015c766842956cc0c344c2ccd
diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md
index 36c15ffd52..48a80ccb92 100644
--- a/docs/subsystems/session.md
+++ b/docs/subsystems/session.md
@@ -800,18 +800,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise
-
-/**
- * Stream one job's retained record output from an absolute byte offset,
- * then its terminal status once settled and drained. Non-consuming: the
- * model-facing cursor and notice state never observe these reads. The
- * request's session resolves the fenced-read caller; the registry rejects a
- * job the session does not own, a job without a record, or an unknown job.
- * @param request - target job, owning session, and optional resume offset.
- * @param signal - cancellation owned by the Remote stream carrier.
- * @returns anchor, coalesced output frames, and the terminal status.
- */
-@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable
```
Types: [SessionId](core.md) · [SessionInspection](persistence.md) · [SessionSearchRequest](session-query.md)
diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md
index b31fd6593c..df5cfd631a 100644
--- a/docs/subsystems/session.zh.md
+++ b/docs/subsystems/session.zh.md
@@ -804,18 +804,6 @@ inspect( sessionId: SessionId, signal?: AbortSignal, ): Promise
-
-/**
- * Stream one job's retained record output from an absolute byte offset,
- * then its terminal status once settled and drained. Non-consuming: the
- * model-facing cursor and notice state never observe these reads. The
- * request's session resolves the fenced-read caller; the registry rejects a
- * job the session does not own, a job without a record, or an unknown job.
- * @param request - target job, owning session, and optional resume offset.
- * @param signal - cancellation owned by the Remote stream carrier.
- * @returns anchor, coalesced output frames, and the terminal status.
- */
-@Remote({ mode: 'stream' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable
```
Types: [SessionId](core.zh.md) · [SessionInspection](persistence.zh.md) · [SessionSearchRequest](session-query.zh.md)
diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml
index c47c67f406..d40b9dfa53 100644
--- a/packages/api/README.i18n.yaml
+++ b/packages/api/README.i18n.yaml
@@ -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: b4d8ddd84baa411a2675c95dda1701937e06fe6d
-README.zh.md: 5dff7a59219a539c75d7ab85d293cd5b389018ec
+README.md: ad89535e8d482e7a50868cd97e0af50ebda62e4b
+README.zh.md: 7e1957967aadcd93df5e1bb7f6db45cb4636c750
diff --git a/packages/api/README.md b/packages/api/README.md
index b4d8ddd84b..ad89535e8d 100644
--- a/packages/api/README.md
+++ b/packages/api/README.md
@@ -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, 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` |
diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md
index 5dff7a5921..7e1957967a 100644
--- a/packages/api/README.zh.md
+++ b/packages/api/README.zh.md
@@ -28,6 +28,7 @@ kind: "package-group"
|---|---|---|
| [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — |
| [`gateway/`](gateway/README.zh.md) | 承载带类型的单次调用、多路复用 stream 与转发的 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) | 拥有 Session 命令、历史 stream、实时控制状态与 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` |
diff --git a/packages/api/job-controller/README.i18n.yaml b/packages/api/job-controller/README.i18n.yaml
new file mode 100644
index 0000000000..9bef50c478
--- /dev/null
+++ b/packages/api/job-controller/README.i18n.yaml
@@ -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: 1af9cac6574b36fb9ee0fe2bd4ff4a529848c2fe
+README.zh.md: a232d8e8655fea6ccddedb60e222837466705549
diff --git a/packages/api/job-controller/README.md b/packages/api/job-controller/README.md
new file mode 100644
index 0000000000..1af9cac657
--- /dev/null
+++ b/packages/api/job-controller/README.md
@@ -0,0 +1,66 @@
+---
+description: "Host and Client job observation: stream one background job's output record 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 one Remote stream, `job.observe`, delivers a record-declaring job's retained output from an absolute byte offset; the Client half installs `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views the session-header job list renders. The roster itself is not here: `SessionJob` rows still ride the session control stream owned by [`dsh-api-session-controller`](../session-controller/README.md), and a row's `outputTotal` says whether this controller has anything to stream for it.
+
+## 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)
+
+-----
+
+
+## Use this package
+
+The Host controller requires the live Agent registry and the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without them. `job.observe({ sessionId?, jobId, from? })` resolves the fenced-read caller from the request's session — an unowned job needs no session — and yields one `opened` anchor, coalesced `output` frames, then one terminal `status` once the job has settled and its record is drained, after which the stream closes normally. Record reads are non-consuming: the model-facing `job_output` cursor and completion-notice state never observe them. A job the session does not own, a job without a record, or an unknown job rejects the stream. Reconnecting callers resume by passing the last frame's `next` as `from`; a `from` below the oldest retained byte gets a `lossy` first frame instead of an error.
+
+The Client entry provides `ClientJobOutput` (`ctx.jobOutput`) over `ClientJobOutputModel`. `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, records the terminal status or a stream failure on the view, 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 observation (re)opens run on caller stacks that have not declared `remote.job`.
+
+### Config
+
+| Field | Default | Meaning |
+|---|---:|---|
+| `observeFlushMs` | `100` | Coalescing window after new record output before an observation 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.
+
+-----
+
+
+## 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
+
+
+
+- The stream is process-local: a Host restart loses every record, and a resumed observation then anchors on an empty registry.
+- Per-session fencing is enforced by the registry read; the Remote layer itself serves any connected browser, matching the session control stream's `jobsBySession` broadcast.
+
+
+### Dev Note
+
+
+Working context for maintainers — click to expand
+
+None.
+
+
+
+**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 offset relations this stream forwards.
diff --git a/packages/api/job-controller/README.zh.md b/packages/api/job-controller/README.zh.md
new file mode 100644
index 0000000000..a232d8e865
--- /dev/null
+++ b/packages/api/job-controller/README.zh.md
@@ -0,0 +1,66 @@
+---
+description: "Host 与 Client 的 job 观测:把一个后台任务的输出 record 流式送到浏览器,不触碰模型的消耗型游标。"
+kind: "package-reference"
+---
+# Job Controller
+
+[English](README.md) | 中文
+
+## 概述
+
+`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它唯一的 Remote 流 `job.observe` 从绝对字节偏移发送声明了 record 的 job 的保留输出;Client 半侧安装 `ctx.jobOutput`——按 job 引用计数的观测服务,会话头部任务列表渲染其累积视图。名册不在这里:`SessionJob` 行仍随 [`dsh-api-session-controller`](../session-controller/README.zh.md) 拥有的会话控制流到达,行上的 `outputTotal` 说明本控制器是否有东西可流。
+
+## 目录
+
+- [使用本包](#use-this-package)
+- [模型体验](#model-experience)
+- [已知限制与延期工作](#known-limitations-and-deferred-work)
+- [开发备注](#dev-note)
+
+-----
+
+
+## 使用本包
+
+Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.observe({ sessionId?, jobId, from? })` 由请求的 session 解析围栏读取者——无主 job 不需要 session——先产出一个 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且 record 排干后产出一个终态 `status`,随后流正常关闭。record 读取是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。session 不拥有的 job、没有 record 的 job 或未知 job 会拒绝该流。重连方把上一帧的 `next` 作为 `from` 续读;低于最老保留字节的 `from` 得到带 `lossy` 的首帧而非错误。
+
+Client 入口在 `ClientJobOutputModel` 之上提供 `ClientJobOutput`(`ctx.jobOutput`)。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,把终态或流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为观测的(重)开启跑在未声明 `remote.job` 的调用栈上。
+
+### 配置
+
+| 字段 | 默认值 | 含义 |
+|---|---:|---|
+| `observeFlushMs` | `100` | 新 record 输出到一次观测读取之间的聚合窗口,毫秒 |
+| `observeMaxFrameBytes` | `65,536` | 每个观测输出帧的软字节预算;更大的单块整块发送 |
+
+生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-job-controller)是所有受支持字段及其 JSDoc 的完整来源。
+
+-----
+
+
+## 模型体验
+
+无。job 观测是浏览器与 Host 的控制状态;它不注册任何提示词、工具或会话事件。模型对同一工作的视图仍在 [`dsh-tool-jobs`](../../jobs/tool-jobs/README.zh.md)。
+
+#### KV 缓存影响
+
+无直接影响;观测读取从不触碰模型请求。
+
+## 已知限制与延期工作
+
+
+
+- 流是进程本地的:Host 重启丢失全部 record,续读的观测随后锚定在空注册表上。
+- 按 session 的围栏由注册表读取强制;Remote 层本身服务任何已连接浏览器,与会话控制流对 `jobsBySession` 的广播一致。
+
+
+### 开发备注
+
+
+维护者的工作上下文——点击展开
+
+无。
+
+
+
+**运行时不变式:** 不发布伴生入口。控制器是 `ctx.jobs` 读取的无状态投影;本流转发的偏移关系由注册表自己的 `@deepseek-ai/dsh-jobs/invariant` 拥有。
diff --git a/packages/api/job-controller/package.json b/packages/api/job-controller/package.json
new file mode 100644
index 0000000000..0f0e46aafe
--- /dev/null
+++ b/packages/api/job-controller/package.json
@@ -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.2-alpha.5",
+ "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:^"
+ }
+}
diff --git a/packages/api/job-controller/src/client/index.ts b/packages/api/job-controller/src/client/index.ts
new file mode 100644
index 0000000000..a76d507332
--- /dev/null
+++ b/packages/api/job-controller/src/client/index.ts
@@ -0,0 +1,34 @@
+/**
+ * Job Controller client half: installs `ctx.jobOutput` over the generated
+ * `job` Remote namespace. The plugin resolves both Remote faces it drives
+ * while its own context is current, because observation (re)opens run on
+ * caller stacks — a React event, a carrier retry — whose dynamic context has
+ * not declared `remote.job`.
+ * @module @deepseek-ai/dsh-api-job-controller/client
+ */
+
+import type { Context } from '@deepseek-ai/cordis'
+import { ClientJobOutputModel } from './model.ts'
+import { ClientJobOutput } from './service.ts'
+import type { JobObserveRemote } from './service.ts'
+
+export { ClientJobOutputModel } from './model.ts'
+export type { JobOutputSnapshot, JobOutputSource, ObservedJob } from './model.ts'
+export { ClientJobOutput } from './service.ts'
+export type { IJobOutput, JobObserveRemote, JobRemote } from './service.ts'
+export type { JobObserveFrame, JobObserveRequest, JobObserveStatus, JobWireChunk } from '../types.ts'
+
+/** Required Client Remote services. */
+export const inject = ['remote', 'remote.job']
+
+/**
+ * Install the client job-output service.
+ * @param ctx - Client root Context.
+ */
+export function apply(ctx: Context): void {
+ const remotes = ctx.remote as unknown as JobObserveRemote
+ new ClientJobOutput(ctx, {
+ $stream: options => remotes.$stream(options),
+ job: remotes.job,
+ }, new ClientJobOutputModel())
+}
diff --git a/packages/api/job-controller/src/client/model.ts b/packages/api/job-controller/src/client/model.ts
new file mode 100644
index 0000000000..1c44aa039a
--- /dev/null
+++ b/packages/api/job-controller/src/client/model.ts
@@ -0,0 +1,179 @@
+/**
+ * React-free client job-record observation state: per-job accumulated output
+ * views fed by `job.observe` frames. Pure data plus subscriptions — transport
+ * wiring stays in the client service, UI stays in slot components.
+ * @module @deepseek-ai/dsh-api-job-controller/client/model
+ */
+
+import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
+import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
+import type { JobObserveFrame, JobObserveStatus } from '../types.ts'
+
+/** Bounded per-job render tail, in UTF-16 code units. */
+const RENDER_TAIL_LIMIT = 128 * 1024
+
+/** One observed job's live view state. */
+export interface ObservedJob {
+ readonly jobId: JobId
+ /** Accumulated output tail, bounded to the render limit. */
+ readonly text: string
+ /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */
+ readonly gapBefore: boolean
+ /** Latest status delivered on the observation stream. */
+ readonly status: JobObserveStatus
+ readonly detail?: string
+ /** True while the observation stream is open and the job has not settled. */
+ readonly streaming: boolean
+ /** Terminal observation failure, when the stream ended abnormally. */
+ readonly error?: string
+}
+
+/** Immutable client job-output state. */
+export interface JobOutputSnapshot {
+ /** Live observation state keyed by job id. */
+ readonly observed: Readonly>
+}
+
+/** Bare observable source for the client job-output snapshot. */
+export interface JobOutputSource {
+ /** Read the identity-stable current snapshot. */
+ getSnapshot(): JobOutputSnapshot
+ /**
+ * Subscribe to snapshot changes.
+ * @param listener - invalidation callback.
+ * @returns unsubscribe function.
+ */
+ subscribe(listener: () => void): () => void
+}
+
+/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */
+interface ObservedState {
+ view: ObservedJob
+ /** Resume offset for the next generation's `from`. */
+ cursor: number | undefined
+}
+
+/** Owns per-job observation state. */
+export class ClientJobOutputModel implements JobOutputSource {
+ private readonly observedStates = new Map()
+ private readonly listeners = new Set<() => void>()
+ private snapshotCache: JobOutputSnapshot = { observed: {} }
+ private snapshotDirty = false
+
+ getSnapshot(): JobOutputSnapshot {
+ if (this.snapshotDirty) {
+ const observed: Record = {}
+ for (const [id, state] of this.observedStates) observed[id] = state.view
+ this.snapshotCache = { observed }
+ this.snapshotDirty = false
+ }
+ return this.snapshotCache
+ }
+
+ subscribe(listener: () => void): () => void {
+ this.listeners.add(listener)
+ return () => { this.listeners.delete(listener) }
+ }
+
+ /**
+ * The resume offset for one job's next observation generation.
+ * @param id - observed job.
+ * @returns the last accepted `next`, or undefined for a fresh observation.
+ */
+ cursorOf(id: JobId): number | undefined {
+ return this.observedStates.get(String(id))?.cursor
+ }
+
+ /**
+ * Install or reset observation state when a generation's anchor arrives.
+ * @param id - observed job.
+ * @param frame - the generation's `opened` anchor.
+ */
+ observeOpened(id: JobId, frame: Extract): void {
+ const existing = this.observedStates.get(String(id))
+ // A fresh view anchored past offset zero starts after an evicted head
+ // (fresh observations anchor at the registry's earliest retained byte), so
+ // it owes the same gap mark a live observer earned from lossy reads. A
+ // resume that already accumulated text keeps its recorded gap state.
+ const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0
+ const view: ObservedJob = {
+ jobId: id,
+ text: existing?.view.text ?? '',
+ gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead,
+ status: frame.status,
+ ...frame.detail !== undefined ? { detail: frame.detail } : {},
+ streaming: true,
+ }
+ this.observedStates.set(String(id), { view, cursor: frame.from })
+ this.changed()
+ }
+
+ /**
+ * Append one output frame's chunks to the bounded render tail.
+ * @param id - observed job.
+ * @param frame - a coalesced `output` frame.
+ */
+ observeOutput(id: JobId, frame: Extract): void {
+ const state = this.observedStates.get(String(id))
+ /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
+ if (state === undefined) return
+ let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('')
+ let gapBefore = state.view.gapBefore || frame.lossy === true
+ || frame.chunks.some(chunk => chunk.gapBefore === true)
+ if (text.length > RENDER_TAIL_LIMIT) {
+ let cut = text.length - RENDER_TAIL_LIMIT
+ // Never split a surrogate pair at the render bound.
+ const unit = text.charCodeAt(cut)
+ if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1
+ text = text.slice(cut)
+ gapBefore = true
+ }
+ state.view = { ...state.view, text, gapBefore }
+ state.cursor = frame.next
+ this.changed()
+ }
+
+ /**
+ * Record the terminal status delivered at the end of the observation stream.
+ * @param id - observed job.
+ * @param frame - the terminal `status` frame.
+ */
+ observeStatus(id: JobId, frame: Extract): void {
+ const state = this.observedStates.get(String(id))
+ /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
+ if (state === undefined) return
+ state.view = {
+ ...state.view,
+ status: frame.status,
+ ...frame.detail !== undefined ? { detail: frame.detail } : {},
+ streaming: false,
+ }
+ this.changed()
+ }
+
+ /**
+ * Record a terminal observation failure.
+ * @param id - observed job.
+ * @param error - the stream's terminal failure.
+ */
+ observeFailed(id: JobId, error: unknown): void {
+ const state = this.observedStates.get(String(id))
+ if (state === undefined) return
+ state.view = { ...state.view, streaming: false, error: String(error) }
+ this.changed()
+ }
+
+ /**
+ * Drop observation state after the last observer stops.
+ * @param id - the no-longer-observed job.
+ */
+ observeStopped(id: JobId): void {
+ if (!this.observedStates.delete(String(id))) return
+ this.changed()
+ }
+
+ private changed(): void {
+ this.snapshotDirty = true
+ notifySubscribers(this.listeners, 'job output')
+ }
+}
diff --git a/packages/api/job-controller/src/client/service.ts b/packages/api/job-controller/src/client/service.ts
new file mode 100644
index 0000000000..385a0b92ad
--- /dev/null
+++ b/packages/api/job-controller/src/client/service.ts
@@ -0,0 +1,179 @@
+/**
+ * The `ctx.jobOutput` client service: reference-counted per-job observation
+ * streams over `job.observe`, so overlapping viewers of one job share one
+ * stream and resume from the model's cursor across reconnects.
+ * @module @deepseek-ai/dsh-api-job-controller/client/service
+ */
+
+import { Service, type Context } from '@deepseek-ai/cordis'
+import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
+import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import type { JobObserveFrame, JobObserveRequest } from '../types.ts'
+import type { ClientJobOutputModel, JobOutputSource } from './model.ts'
+
+/** The generated `job` namespace face the observation runner drives. */
+export interface JobRemote {
+ /**
+ * Open one observation generation.
+ * @param request - target job, owning session, and optional resume offset.
+ * @param signal - generation cancellation.
+ * @returns the frame sequence of one generation.
+ */
+ observe(request: JobObserveRequest, signal?: AbortSignal): AsyncIterable
+}
+
+/** Remote faces the observation runner drives: the Gateway stream factory and the `job` namespace. */
+export interface JobObserveRemote {
+ readonly $stream: ClientRemote['$stream']
+ readonly job: JobRemote
+}
+
+/** The job-output client service face. */
+export interface IJobOutput {
+ /** Per-job observation state. */
+ readonly state: JobOutputSource
+ /**
+ * Start observing one job's live record; reference-counted, so two viewers
+ * of the same job share one stream.
+ * @param sessionId - owning session used for the fenced read; undefined for an unowned job.
+ * @param id - job to observe.
+ * @returns stop function releasing this observer's reference.
+ */
+ observe(sessionId: SessionId | undefined, id: JobId): () => void
+}
+
+/** One reference-counted observation stream. */
+interface ObservationEntry {
+ refs: number
+ stopped: boolean
+ dispose: () => Promise
+}
+
+declare module '@deepseek-ai/cordis' {
+ interface Context {
+ /** React-free client job-record observation control. */
+ jobOutput: IJobOutput
+ }
+}
+
+/** Owns the bare job-output snapshot and per-job observation streams. */
+export class ClientJobOutput extends Service implements IJobOutput {
+ readonly state: JobOutputSource
+ private readonly entries = new Map()
+
+ /**
+ * @param ctx - client root Context.
+ * @param remote - the Gateway stream factory plus the generated `job` namespace, both resolved by the caller.
+ * @param model - shared client job-output model.
+ */
+ constructor(
+ ctx: Context,
+ private readonly remote: JobObserveRemote,
+ private readonly model: ClientJobOutputModel,
+ ) {
+ super(ctx, 'jobOutput')
+ this.state = model
+ ctx.effect(() => () => {
+ const open = [...this.entries.values()]
+ this.entries.clear()
+ for (const entry of open) {
+ entry.stopped = true
+ void entry.dispose()
+ }
+ }, 'job-controller.client.observations')
+ }
+
+ observe(sessionId: SessionId | undefined, id: JobId): () => void {
+ const key = String(id)
+ const existing = this.entries.get(key)
+ if (existing !== undefined && !existing.stopped) {
+ existing.refs += 1
+ return this.releaser(key, existing)
+ }
+ const entry = this.startObservation(sessionId, id)
+ this.entries.set(key, entry)
+ return this.releaser(key, entry)
+ }
+
+ /**
+ * Release closures bind the exact entry they were minted for, never the
+ * map's current occupant: a later `observe()` on the same id may have
+ * replaced a stopped entry, and decrementing or disposing through the key
+ * alone would tear down that newer stream's references.
+ */
+ private releaser(key: string, entry: ObservationEntry): () => void {
+ let released = false
+ return () => {
+ if (released) return
+ released = true
+ entry.refs -= 1
+ if (entry.refs > 0) return
+ if (this.entries.get(key) === entry) this.entries.delete(key)
+ entry.stopped = true
+ void entry.dispose().then(() => {
+ // Clear the view only while no successor observation holds the key:
+ // a re-expand inside the dispose round-trip already re-anchored the
+ // model, and a stale clear would blank its panel for good.
+ if (this.entries.has(key)) return
+ // The key is the stringified branded id this releaser was minted for.
+ this.model.observeStopped(key as JobId)
+ })
+ }
+ }
+
+ private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry {
+ const name = `job observation ${String(id)}`
+ const stream = this.remote.$stream({
+ name,
+ open: (signal) => {
+ const from = this.model.cursorOf(id)
+ return this.remote.job.observe(
+ {
+ jobId: id,
+ ...sessionId !== undefined ? { sessionId } : {},
+ ...from !== undefined ? { from } : {},
+ },
+ signal,
+ )
+ },
+ // A premature end after the anchor is retryable (a Host reload closes the
+ // generation); resuming from the cursor loses nothing. An end before the
+ // anchor is terminal.
+ ended: accepted => accepted
+ ? new RemoteStreamCarrierError(`${name} ended before settlement`)
+ : new Error(`${name} ended before its anchor`),
+ })
+ const entry: ObservationEntry = {
+ refs: 1,
+ stopped: false,
+ dispose: () => stream.dispose(),
+ }
+ void (async () => {
+ try {
+ for await (const item of stream) {
+ const frame = item.value
+ if (frame.type === 'opened') {
+ this.model.observeOpened(id, frame)
+ item.accept()
+ continue
+ }
+ if (frame.type === 'output') {
+ this.model.observeOutput(id, frame)
+ continue
+ }
+ // Terminal status: leave the loop before the generation end is
+ // classified, then close the stream for good.
+ this.model.observeStatus(id, frame)
+ break
+ }
+ } catch (error) {
+ if (!entry.stopped) this.model.observeFailed(id, error)
+ } finally {
+ entry.stopped = true
+ void entry.dispose()
+ }
+ })()
+ return entry
+ }
+}
diff --git a/packages/api/job-controller/src/index.ts b/packages/api/job-controller/src/index.ts
new file mode 100644
index 0000000000..27e85a0ed0
--- /dev/null
+++ b/packages/api/job-controller/src/index.ts
@@ -0,0 +1,86 @@
+/**
+ * Host job Remote owner: streams one background job's observation record to
+ * browsers over the generated `job` namespace. The roster itself rides the
+ * session control stream (`jobsBySession`, owned by `dsh-api-session-controller`);
+ * this controller carries only the non-consuming record output.
+ * @module @deepseek-ai/dsh-api-job-controller
+ */
+
+import { Context } from '@deepseek-ai/cordis'
+import z from '@deepseek-ai/schemastery'
+import type {} from '@deepseek-ai/dsh-agent'
+import type {} from '@deepseek-ai/dsh-jobs'
+import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
+import { observeJobRecord } from './observe.ts'
+import type { JobObserveFrame, JobObserveRequest } from './types.ts'
+
+export type * from './types.ts'
+
+declare module '@deepseek-ai/cordis' {
+ interface Context {
+ /** Host job Remote namespace owner. */
+ jobController: JobController
+ }
+}
+
+/** Default coalescing window between record reads, in milliseconds. */
+const DEFAULT_OBSERVE_FLUSH_MS = 100
+
+/** Default soft byte budget per output frame. */
+const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024
+
+/** Job Controller deployment policy. */
+export interface Config {
+ /** Coalescing window after new record output before an observation 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
+}
+
+/** Host service backing the generated `ctx.remote.job` namespace. */
+export class JobController extends TypertRemoteService {
+ static inject = ['agents', 'jobs', 'typert']
+
+ static Config: z = z.object({
+ observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS),
+ observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES),
+ })
+
+ private readonly observeFlushMs: number
+ private readonly observeMaxFrameBytes: number
+
+ /**
+ * @param ctx - Host context carrying the live Agent registry and the job registry.
+ * @param config - observation cadence and framing policy.
+ */
+ constructor(ctx: Context, config: Config) {
+ super(ctx, 'jobController', { namespace: 'job' })
+ // schemastery (the exported Config schema) has already filled the defaulted
+ // fields; the assertion records that resolution, not a hidden fallback.
+ const resolved = config as Required
+ this.observeFlushMs = resolved.observeFlushMs
+ this.observeMaxFrameBytes = resolved.observeMaxFrameBytes
+ }
+
+ /**
+ * Stream one job's retained record output from an absolute byte offset,
+ * then its terminal status once settled and drained. Non-consuming: the
+ * model-facing cursor and notice state never observe these reads. The
+ * request's session resolves the fenced-read caller; the registry rejects a
+ * job the session does not own, a job without a record, or an unknown job.
+ * @param request - target job, owning session, and optional resume offset.
+ * @param signal - cancellation owned by the Remote stream carrier.
+ * @returns anchor, coalesced output frames, and the terminal status.
+ */
+ @Remote({ mode: 'stream' })
+ observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable {
+ const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId)
+ return observeJobRecord(this.ctx.jobs, request, {
+ flushMs: this.observeFlushMs,
+ maxFrameBytes: this.observeMaxFrameBytes,
+ caller,
+ }, signal)
+ }
+}
+
+export default JobController
diff --git a/packages/api/session-controller/src/observe-job.ts b/packages/api/job-controller/src/observe.ts
similarity index 94%
rename from packages/api/session-controller/src/observe-job.ts
rename to packages/api/job-controller/src/observe.ts
index 795e786455..731f40b2e2 100644
--- a/packages/api/session-controller/src/observe-job.ts
+++ b/packages/api/job-controller/src/observe.ts
@@ -3,7 +3,7 @@
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { JobRegistry, JobSnapshot } from '@deepseek-ai/dsh-jobs'
-import type { SessionJobWireChunk, SessionObserveJobFrame, SessionObserveJobRequest } from './types.ts'
+import type { JobObserveFrame, JobObserveRequest, JobWireChunk } from './types.ts'
/** Cadence and framing bounds for one observation generation. */
export interface ObserveJobOptions {
@@ -91,16 +91,16 @@ function sleep(ms: number, signal: AbortSignal): Promise {
*/
export async function* observeJobRecord(
registry: JobRegistry,
- request: SessionObserveJobRequest,
+ request: JobObserveRequest,
options: ObserveJobOptions,
signal: AbortSignal,
-): AsyncIterable {
+): AsyncIterable {
if (request.from !== undefined && (!Number.isSafeInteger(request.from) || request.from < 0)) {
throw new Error(`invalid observe offset: expected a non-negative safe integer, got ${JSON.stringify(request.from)}`)
}
signal.throwIfAborted()
// The brand is nominal typing only; the wire boundary stamps it here rather
- // than value-importing the optional registry package's constructor.
+ // than value-importing the registry package's constructor.
const id = String(request.jobId) as JobId
const waiter = new OutputWaiter()
// Subscribe before the first read so an append between the anchor read and
@@ -153,12 +153,12 @@ export async function* observeJobRecord(
/** Split one read into frames along the soft per-frame byte budget. */
function* outputFrames(
- chunks: readonly SessionJobWireChunk[],
+ chunks: readonly JobWireChunk[],
next: number,
lossy: boolean,
maxFrameBytes: number,
-): Iterable {
- let batch: SessionJobWireChunk[] = []
+): Iterable {
+ let batch: JobWireChunk[] = []
let batchBytes = 0
let flaggedLossy = lossy
for (const chunk of chunks) {
diff --git a/packages/api/job-controller/src/types.ts b/packages/api/job-controller/src/types.ts
new file mode 100644
index 0000000000..98df4d8f65
--- /dev/null
+++ b/packages/api/job-controller/src/types.ts
@@ -0,0 +1,75 @@
+/**
+ * Wire types of the generated `job` Remote namespace: the observation request
+ * and the frames its stream yields. Client-safe: no Host imports.
+ * @module @deepseek-ai/dsh-api-job-controller/types
+ */
+
+import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+
+/**
+ * A job's lifecycle state on the wire. Spelled out rather than imported from
+ * the registry package so this client-safe face pulls no Host declaration
+ * merges into browser programs; it is the registry's `JobStatus` union.
+ */
+export type JobObserveStatus = 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
+
+/** Target of one `job.observe` stream: the job, its owning session, and an optional resume offset. */
+export interface JobObserveRequest {
+ /**
+ * Owning session, resolved to the live agent for the fenced record read.
+ * Omitted for an unowned job, which any caller may observe.
+ */
+ readonly sessionId?: SessionId
+ readonly jobId: JobId
+ /**
+ * Absolute byte offset to resume from (a prior frame's `next`). Omitted, the
+ * stream starts at the oldest retained byte.
+ */
+ readonly from?: number
+}
+
+/** One retained record chunk on the wire. `channel` widens to string like a job's `kind`. */
+export interface JobWireChunk {
+ /** Absolute offset of the chunk's first byte. */
+ readonly at: number
+ readonly text: string
+ readonly channel?: string
+ /** Bytes immediately before this chunk were lost at the producer or to retention. */
+ readonly gapBefore?: true
+}
+
+/**
+ * Job observation stream frames: one `opened` anchor, then coalesced `output`
+ * batches, then — once the job has settled and its retained record is
+ * drained — one terminal `status`, after which the stream closes normally.
+ * Status rides the same stream as output so settlement can never race a
+ * still-open output channel.
+ */
+export type JobObserveFrame =
+ | {
+ readonly type: 'opened'
+ readonly jobId: JobId
+ /** Offset the first `output` frame continues from. */
+ readonly from: number
+ /** Oldest retained byte at open time; `from < earliest` means the head is gone. */
+ readonly earliest: number
+ /** Total bytes produced at open time. */
+ readonly total: number
+ readonly status: JobObserveStatus
+ readonly detail?: string
+ }
+ | {
+ readonly type: 'output'
+ readonly chunks: readonly JobWireChunk[]
+ /** Offset to resume from after this frame. */
+ readonly next: number
+ /** Bytes between the requested offset and `chunks` were already evicted. */
+ readonly lossy?: true
+ }
+ | {
+ readonly type: 'status'
+ readonly status: JobObserveStatus
+ readonly detail?: string
+ readonly finishedAt?: number
+ }
diff --git a/packages/api/job-controller/tests/client-apply.client.spec.ts b/packages/api/job-controller/tests/client-apply.client.spec.ts
new file mode 100644
index 0000000000..ebbe67e98e
--- /dev/null
+++ b/packages/api/job-controller/tests/client-apply.client.spec.ts
@@ -0,0 +1,65 @@
+import { Context } from '@deepseek-ai/cordis'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+import { RemoteStream, type RemoteStreamOptions } from '@deepseek-ai/dsh-api-gateway/client'
+import type { SessionId } from '@deepseek-ai/dsh-session/types'
+import { afterEach, describe, expect, it } from 'vitest'
+import * as JobClient from '../src/client/index.ts'
+import type { JobObserveFrame } from '../src/types.ts'
+
+const contexts = new Set()
+
+afterEach(async () => {
+ await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() }))
+ contexts.clear()
+})
+
+/** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */
+async function mount(): Promise<{ ctx: Context; observeCalls: unknown[] }> {
+ const ctx = new Context()
+ contexts.add(ctx)
+ const observeCalls: unknown[] = []
+ const connection: ConnectionHandle = {
+ isLoopback: true,
+ generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} },
+ state: { getSnapshot: () => 'connected' as const, subscribe: () => () => {} },
+ rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')) },
+ reconnect: () => {},
+ registerGenerationSource: () => () => {},
+ start: () => ({ stop: () => {} }),
+ }
+ const job = {
+ // The fake stream never yields; opening and releasing must leave no view behind.
+ observe: (request: unknown) => {
+ observeCalls.push(request)
+ return (async function* (): AsyncGenerator {})()
+ },
+ }
+ ctx.reflect.provide('remote', {
+ $stream: - (options: RemoteStreamOptions
- ) => new RemoteStream(connection, options),
+ job,
+ })
+ ctx.reflect.provide('remote.job', job)
+ await ctx.plugin(JobClient)
+ return { ctx, observeCalls }
+}
+
+async function flush(): Promise {
+ for (let index = 0; index < 12; index++) await Promise.resolve()
+}
+
+describe('Job Controller Client apply', () => {
+ it('declares the Remote services it binds', () => {
+ expect(JobClient.inject).toEqual(['remote', 'remote.job'])
+ })
+
+ it('installs ctx.jobOutput over the captured job namespace', async () => {
+ const { ctx, observeCalls } = await mount()
+ expect(ctx.jobOutput).toBeDefined()
+ const stop = ctx.jobOutput.observe('session-1' as SessionId, 'bash-1' as never)
+ await flush()
+ expect(observeCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }])
+ stop()
+ await flush()
+ expect(ctx.jobOutput.state.getSnapshot().observed).toEqual({})
+ })
+})
diff --git a/packages/api/session-controller/tests/job-output.client.spec.ts b/packages/api/job-controller/tests/model.client.spec.ts
similarity index 98%
rename from packages/api/session-controller/tests/job-output.client.spec.ts
rename to packages/api/job-controller/tests/model.client.spec.ts
index a4ca2933dc..69260ff3fc 100644
--- a/packages/api/session-controller/tests/job-output.client.spec.ts
+++ b/packages/api/job-controller/tests/model.client.spec.ts
@@ -2,8 +2,9 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import { ClientJobOutput, ClientJobOutputModel } from '../src/client/job-output.ts'
-import type { SessionObserveJobFrame } from '../src/types.ts'
+import { ClientJobOutputModel } from '../src/client/model.ts'
+import { ClientJobOutput } from '../src/client/service.ts'
+import type { JobObserveFrame } from '../src/types.ts'
const ID = 'bash-1' as JobId
@@ -152,12 +153,12 @@ class FakeStream {
deferDispose = false
private disposeRelease: (() => void) | undefined
private disposePending: Promise | undefined
- private readonly frames: SessionObserveJobFrame[] = []
+ private readonly frames: JobObserveFrame[] = []
private waiter: (() => void) | undefined
private failure: Error | undefined
readonly accepts: number[] = []
- push(frame: SessionObserveJobFrame): void {
+ push(frame: JobObserveFrame): void {
this.frames.push(frame)
this.waiter?.()
}
@@ -218,8 +219,8 @@ function bench() {
streams.push({ options, stream })
return stream
},
- session: {
- observeJob: (request: unknown) => {
+ job: {
+ observe: (request: unknown) => {
observeCalls.push(request)
return { [Symbol.asyncIterator]: async function* () { /* never yields */ } }
},
diff --git a/packages/api/session-controller/tests/observe-job.host.spec.ts b/packages/api/job-controller/tests/observe.host.spec.ts
similarity index 89%
rename from packages/api/session-controller/tests/observe-job.host.spec.ts
rename to packages/api/job-controller/tests/observe.host.spec.ts
index 06a67848ec..5bbb5a8c66 100644
--- a/packages/api/session-controller/tests/observe-job.host.spec.ts
+++ b/packages/api/job-controller/tests/observe.host.spec.ts
@@ -6,10 +6,11 @@ import { JobId } from '@deepseek-ai/dsh-jobs'
import type { JobOutcome, RunningJob } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
-import { observeJobRecord } from '../src/observe-job.ts'
-import type { ObserveJobOptions } from '../src/observe-job.ts'
-import type { SessionObserveJobFrame } from '../src/types.ts'
-import { createSessionTestController } from './test-remote.ts'
+import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
+import { JobController } from '../src/index.ts'
+import { observeJobRecord } from '../src/observe.ts'
+import type { ObserveJobOptions } from '../src/observe.ts'
+import type { JobObserveFrame } from '../src/types.ts'
async function harness() {
const ctx = new Context()
@@ -88,7 +89,7 @@ const OBSERVE: Omit = { flushMs: 5, maxFrameBytes:
describe('observeJobRecord', () => {
function observed(ctx: Context, request: { jobId: JobId; from?: number }, caller?: Agent) {
const abort = new AbortController()
- const frames: SessionObserveJobFrame[] = []
+ const frames: JobObserveFrame[] = []
const stream = observeJobRecord(ctx.jobs, request, { ...OBSERVE, caller }, abort.signal)
const done = (async () => {
for await (const frame of stream) frames.push(frame)
@@ -142,7 +143,7 @@ describe('observeJobRecord', () => {
job.append('y'.repeat(30))
await job.settle({ status: 'completed' })
const abort = new AbortController()
- const frames = await collect(
+ const frames = await collect(
observeJobRecord(ctx.jobs, { jobId: job.id }, {
flushMs: 5,
maxFrameBytes: 30,
@@ -165,7 +166,7 @@ describe('observeJobRecord', () => {
job.append('c'.repeat(40))
await job.settle({ status: 'completed' })
const abort = new AbortController()
- const frames = await collect(
+ const frames = await collect(
observeJobRecord(ctx.jobs, { jobId: job.id, from: 0 }, {
flushMs: 5,
maxFrameBytes: 40,
@@ -278,28 +279,30 @@ describe('observeJobRecord', () => {
})
})
-describe('SessionController.observeJob', () => {
- const defaults = {
- defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }),
- cwd: '/tmp',
- }
-
- it('refuses observation without a job registry', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- const controller = createSessionTestController(ctx, defaults)
- expect(() => controller.observeJob({ jobId: JobId('bash-1') }, new AbortController().signal))
- .toThrow(/job observation unavailable/)
- })
-
- it('resolves the fenced-read caller from the request session', async () => {
+describe('JobController.observe', () => {
+ async function controllerHarness() {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
+ await ctx.plugin(TypertRegistry)
ctx.jobs.attachController('observe-job-test')
- const controller = createSessionTestController(ctx, defaults)
+ await ctx.plugin(JobController, {})
+ return ctx
+ }
+
+ it('waits for the job registry instead of serving without one', async () => {
+ const ctx = new Context()
+ await ctx.plugin(SessionStore)
+ await ctx.plugin(AgentRegistry)
+ await ctx.plugin(TypertRegistry)
+ ctx.plugin(JobController, {})
+ await new Promise(resolve => setTimeout(resolve, 0))
+ expect(ctx.get('jobController')).toBeUndefined()
+ })
+
+ it('resolves the fenced-read caller from the request session', async () => {
+ const ctx = await controllerHarness()
const session = ctx.sessions.create(SessionId('observing-session'))
const owner = {
id: session.id,
@@ -314,8 +317,8 @@ describe('SessionController.observeJob', () => {
await job.settle({ status: 'completed' })
const abort = new AbortController()
- const frames = await collect(
- controller.observeJob({ sessionId: session.id, jobId: job.id }, abort.signal),
+ const frames = await collect(
+ ctx.jobController.observe({ sessionId: session.id, jobId: job.id }, abort.signal),
3,
abort,
)
@@ -323,19 +326,14 @@ describe('SessionController.observeJob', () => {
})
it('observes an unowned job without a request session', async () => {
- const ctx = new Context()
- await ctx.plugin(SessionStore)
- await ctx.plugin(AgentRegistry)
- await ctx.plugin(LocalJobRegistry)
- ctx.jobs.attachController('observe-job-test')
- const controller = createSessionTestController(ctx, defaults)
+ const ctx = await controllerHarness()
const job = startRecordJob(ctx, { label: 'unowned run' })
job.append('open access')
await job.settle({ status: 'completed' })
const abort = new AbortController()
- const frames = await collect(
- controller.observeJob({ jobId: job.id }, abort.signal),
+ const frames = await collect(
+ ctx.jobController.observe({ jobId: job.id }, abort.signal),
3,
abort,
)
diff --git a/packages/api/job-controller/tsconfig.client.json b/packages/api/job-controller/tsconfig.client.json
new file mode 100644
index 0000000000..34a2202060
--- /dev/null
+++ b/packages/api/job-controller/tsconfig.client.json
@@ -0,0 +1,23 @@
+{
+ "extends": "../../../tsconfig.base.client.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
+ },
+ "files": [
+ "src/client/index.ts",
+ "src/client/model.ts",
+ "src/client/service.ts",
+ "src/types.ts"
+ ],
+ "references": [
+ { "path": "../../../vendor/cordis" },
+ { "path": "../gateway/tsconfig.client.json" },
+ { "path": "../../client/connection/tsconfig.client.json" },
+ { "path": "../../client/store" },
+ { "path": "../../core/session" },
+ { "path": "../../jobs/jobs" },
+ { "path": "../../typert/protocol" }
+ ]
+}
diff --git a/packages/api/job-controller/tsconfig.host.json b/packages/api/job-controller/tsconfig.host.json
new file mode 100644
index 0000000000..96de4dede2
--- /dev/null
+++ b/packages/api/job-controller/tsconfig.host.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types",
+ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo"
+ },
+ "files": [
+ "src/index.ts",
+ "src/observe.ts",
+ "src/types.ts"
+ ],
+ "references": [
+ { "path": "../../../vendor/cordis" },
+ { "path": "../../../vendor/schemastery" },
+ { "path": "../../core/agent" },
+ { "path": "../../core/session" },
+ { "path": "../../jobs/jobs" },
+ { "path": "../../typert/protocol" }
+ ]
+}
diff --git a/packages/api/job-controller/tsconfig.json b/packages/api/job-controller/tsconfig.json
new file mode 100644
index 0000000000..2a0b0e33f7
--- /dev/null
+++ b/packages/api/job-controller/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.host.json" },
+ { "path": "./tsconfig.client.json" }
+ ]
+}
diff --git a/packages/api/job-controller/tsdown.config.ts b/packages/api/job-controller/tsdown.config.ts
new file mode 100644
index 0000000000..0391bc61bf
--- /dev/null
+++ b/packages/api/job-controller/tsdown.config.ts
@@ -0,0 +1,7 @@
+import { clientBundle } from '../../client/tsdown.client.ts'
+
+export default clientBundle(
+ '@deepseek-ai/dsh-api-job-controller',
+ ['lib/types/index.js'],
+ { hostPhase: true },
+)
diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json
index a60099926e..6aabaa7039 100644
--- a/packages/api/remotes/package.json
+++ b/packages/api/remotes/package.json
@@ -62,8 +62,11 @@
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-agent-presets": "workspace:^",
"@deepseek-ai/dsh-api-gateway": "workspace:^",
+ "@deepseek-ai/dsh-api-job-controller": "workspace:^",
+ "@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-api-settings-controller": "workspace:^",
"@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-commands": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
"@deepseek-ai/dsh-credentials": "workspace:^",
@@ -72,14 +75,12 @@
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
+ "@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-settings": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
- "@deepseek-ai/dsh-user-approval": "workspace:^",
- "@deepseek-ai/dsh-user-questions": "workspace:^",
- "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
- "@deepseek-ai/dsh-scope": "workspace:^",
- "@deepseek-ai/dsh-api-session-controller": "workspace:^"
+ "@deepseek-ai/dsh-user-approval": "workspace:^",
+ "@deepseek-ai/dsh-user-questions": "workspace:^"
}
}
diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts
index e7fc7757e9..ce08f3dbe3 100644
--- a/packages/api/remotes/src/client/index.ts
+++ b/packages/api/remotes/src/client/index.ts
@@ -12,6 +12,7 @@ import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote'
import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote'
import subagentsRemote from '@deepseek-ai/dsh-subagent/remote'
import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote'
+import jobRemote from '@deepseek-ai/dsh-api-job-controller/remote'
import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote'
import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
@@ -29,6 +30,8 @@ export type {} from '@deepseek-ai/dsh-subagent/remote'
export type * from '@deepseek-ai/dsh-subagent/client'
export type {} from '@deepseek-ai/dsh-api-session-controller/remote'
export type * from '@deepseek-ai/dsh-api-session-controller/types'
+export type {} from '@deepseek-ai/dsh-api-job-controller/remote'
+export type * from '@deepseek-ai/dsh-api-job-controller/types'
export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote'
export type * from '@deepseek-ai/dsh-api-workspace-controller/types'
export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types'
@@ -146,7 +149,7 @@ export async function apply(ctx: Context): Promise<() => Promise> {
for (const contribution of [
agentPresetsRemote, commandsRemote, settingsControllerRemote, goalsRemote, llmRemote, dynamicRemote,
pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
- subagentsRemote, sessionRemote, workspaceRemote,
+ subagentsRemote, sessionRemote, jobRemote, workspaceRemote,
]) {
disposers.push(await ctx.remote.$mount(contribution))
}
diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json
index 56f664d499..ddd32b2af2 100644
--- a/packages/api/remotes/tsconfig.client.json
+++ b/packages/api/remotes/tsconfig.client.json
@@ -22,7 +22,6 @@
},
{
"path": "../../credentials/credentials"
-
},
{
"path": "../../context/file-reference"
@@ -63,6 +62,9 @@
{
"path": "../../interaction/user-questions"
},
+ {
+ "path": "../job-controller/tsconfig.client.json"
+ },
{
"path": "../session-controller/tsconfig.client.json"
},
diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml
index 8bfe2650e7..fa85bc3ebe 100644
--- a/packages/api/session-controller/README.i18n.yaml
+++ b/packages/api/session-controller/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md
-README.md: 5f1e78b8d95d3fce99c5a533f05ccc6da1455a14
-README.zh.md: f4276b4f8940d66fbf6e76f3b4b49e55b51179d1
+README.md: 4f94607e70a5b963fa50d7a497afbcc97d273cb9
+README.zh.md: 455769f77ce8ad304949624de938e56579024145
diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md
index 5f1e78b8d9..4f94607e70 100644
--- a/packages/api/session-controller/README.md
+++ b/packages/api/session-controller/README.md
@@ -29,7 +29,7 @@ Each endpoint states its activation policy. List, search, attachment, history pa
The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Backwards paging has two verbs: `loadOlder()` pulls one 50-message page, and `loadThrough(seq)` — the turn-jump loader — loops 200-message pages until the window covers the target seq, lowering a shared target on repeated calls, stopping on a page that makes no progress, and reporting busy through the same `loadingOlder` snapshot bit. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events.
-A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record, and `session.observeJob` streams that record from an absolute byte offset — one `opened` anchor, coalesced `output` frames, then one terminal `status` — with the fenced read resolved from the request's session. Record reads are non-consuming: the model-facing cursor and completion-notice state never observe them. The Client adapter exposes `ctx.jobOutput`, the reference-counted per-job observation service whose accumulated views a task-list UI renders; observers share one stream per job and resume from their model cursor across reconnects.
+A `SessionJob` row carries `outputTotal` exactly when the job declared an observation record; streaming that record is the job controller's `job.observe` (see [`dsh-api-job-controller`](../job-controller/README.md)), while this package keeps the roster on the control stream.
The Session object also carries local submission echoes: `session.beginSubmission` inserts one into `SessionSnapshot.pendingSubmissions` synchronously, before the caller serializes and prompts, so a conversation UI can show the message on the submit click's own frame. Session derives each echo's `transcript`, `queued`, or `steering` placement from its current running state and the requested delivery mode, then retains that placement while serialization is in flight. The prompt's `requestId` is the correlation identity: the Host echoes it as the durable user source's `rpcId`, and queue occurrences project it as `SessionQueuedItem.rpcId`. An echo retires one animation frame after its durable event or queue occurrence is observed (the delay keeps it renderable until the replacement is ready), immediately when its identified prompt fails or is abandoned, and as failed on disposal; each retirement fires the registered `onRetire` callback exactly once. Echoes are Client memory only; reload and reconnect rebuild the conversation from durable events alone.
@@ -43,8 +43,6 @@ The Session object also carries local submission echoes: `session.beginSubmissio
| `coldBlankProbeMaxEvents` | `16` | Maximum stat-reported event count of a cold Session eligible for blankness verification; `0` disables the event-count gate |
| `coldBlankProbeMaxBytes` | `1,024` | Maximum stat-reported artifact byte size of a cold Session eligible for blankness verification when the backend offers no event count; `0` disables the byte-size gate |
| `nativeOpen` | platform-detected | Whether Session workspace paths can be handed to a native desktop opener |
-| `observeFlushMs` | `100` | Coalescing window after new record output before a job-observation read, in milliseconds |
-| `observeMaxFrameBytes` | `65,536` | Soft byte budget per job-observation output frame; one larger chunk ships whole |
The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc.
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md
index f4276b4f89..455769f77c 100644
--- a/packages/api/session-controller/README.zh.md
+++ b/packages/api/session-controller/README.zh.md
@@ -29,7 +29,7 @@ kind: "package-reference"
Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。向后分页有两个动词:`loadOlder()` 拉一页 50 条 message,而 `loadThrough(seq)`——轮次跳转加载器——按 200 条 message 一页循环拉取直到窗口覆盖目标 seq,重复调用会下调共享目标,遇到无进展的页即停止,忙碌状态复用同一个 `loadingOlder` 快照位。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。
-`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`,`session.observeJob` 从绝对字节偏移流式发送该 record——一个 `opened` 锚帧、聚合的 `output` 帧、最后一个终态 `status` 帧——围栏读取者由请求的 session 解析。record 读取是非消耗的:模型侧游标与完成通知状态永远观察不到它们。Client adapter 提供 `ctx.jobOutput`:按 job 引用计数的观测服务,任务列表 UI 渲染其累积视图;同一 job 的观察者共享一条流,重连时从模型游标续读。
+`SessionJob` 行恰在该 job 声明了观测 record 时携带 `outputTotal`;流式发送该 record 的是 job 控制器的 `job.observe`(见 [`dsh-api-job-controller`](../job-controller/README.zh.md)),本包只在控制流上保留名册。
Session 对象还承载本地提交回显:`session.beginSubmission` 在调用方序列化与 prompt 之前,同步把一条回显写入 `SessionSnapshot.pendingSubmissions`,会话 UI 因此能在点击提交的当帧显示消息。Session 根据当前运行状态与请求的投递模式推导每条回显的 `transcript`、`queued` 或 `steering` 位置,并在序列化期间保留该位置。prompt 的 `requestId` 是关联标识:Host 把它回显为 durable user source 的 `rpcId`,queue occurrence 也把它投影为 `SessionQueuedItem.rpcId`。回显在观察到其 durable event 或 queue occurrence 后延迟一个动画帧退休,该延迟保证替代内容就绪前回显仍可渲染;带标识的 prompt 失败或被放弃时立即退休,销毁时按 failed 退休;每次退休恰好触发一次注册的 `onRetire` 回调。回显只存在于 Client 内存;刷新与重连只从 durable event 重建会话。
@@ -43,8 +43,6 @@ Session 对象还承载本地提交回显:`session.beginSubmission` 在调用
| `coldBlankProbeMaxEvents` | `16` | stat 报告的事件数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用事件数门槛 |
| `coldBlankProbeMaxBytes` | `1,024` | 后端不提供事件数时,stat 报告的工件字节数不超过该值的冷 Session 才可进行空白状态验证;`0` 禁用字节数门槛 |
| `nativeOpen` | 平台探测 | 是否能把 Session 工作区路径交给原生桌面打开器 |
-| `observeFlushMs` | `100` | 新 record 输出到一次 job 观测读取之间的聚合窗口,毫秒 |
-| `observeMaxFrameBytes` | `65,536` | 每个 job 观测输出帧的软字节预算;更大的单块整块发送 |
生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。
diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts
index 5fc9570a55..4bad2bb8c2 100644
--- a/packages/api/session-controller/src/client/index.ts
+++ b/packages/api/session-controller/src/client/index.ts
@@ -3,7 +3,6 @@
import type { Context } from '@deepseek-ai/cordis'
import type {} from '@deepseek-ai/dsh-agent/types'
import { createSessionControlStream } from './transport.ts'
-import { ClientJobOutput, ClientJobOutputModel } from './job-output.ts'
import { ClientSessions } from './sessions/service.ts'
import type { SessionRemotes } from './sessions/remotes.ts'
import type {} from '../remote-events.ts'
@@ -24,8 +23,6 @@ export type {
} from './transport.ts'
export { createScope, scopeOf } from './scope.ts'
export type { AgentContext, AgentScopeHandle } from './scope.ts'
-export { ClientJobOutput, ClientJobOutputModel } from './job-output.ts'
-export type { IJobOutput, JobOutputSnapshot, JobOutputSource, ObservedJob } from './job-output.ts'
export { SessionCreateError, SessionForkError } from './sessions/service.ts'
export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts'
export type {
@@ -92,14 +89,6 @@ export const inject = [
export function apply(ctx: Context): void {
const remotes = ctx.remote as unknown as SessionRemotes
const sessions = new ClientSessions(ctx, remotes)
- // Resolve the session namespace to a concrete value while this plugin's
- // context is current: observation (re)opens run on caller stacks (a React
- // event, a carrier retry) whose dynamic context has not declared
- // `remote.session`, and a property access there fails the inject check.
- new ClientJobOutput(ctx, {
- $stream: options => remotes.$stream(options),
- session: remotes.session,
- }, new ClientJobOutputModel())
ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) })
ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) })
ctx.remote.$on('api-session/status', (sessionId, running) => {
diff --git a/packages/api/session-controller/src/client/job-output.ts b/packages/api/session-controller/src/client/job-output.ts
deleted file mode 100644
index 933c104497..0000000000
--- a/packages/api/session-controller/src/client/job-output.ts
+++ /dev/null
@@ -1,348 +0,0 @@
-/**
- * React-free client job-record observation: per-job accumulated output views
- * over the `session.observeJob` stream, reference-counted per job so
- * overlapping panels share one stream. The roster itself rides the session
- * control stream (`jobsBySession`); this module carries only the output.
- * @module @deepseek-ai/dsh-api-session-controller/client/job-output
- */
-
-import { Service, type Context } from '@deepseek-ai/cordis'
-import { RemoteStreamCarrierError, type ClientRemote } from '@deepseek-ai/dsh-api-gateway/client'
-import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
-import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
-import type { SessionId } from '@deepseek-ai/dsh-session/types'
-import type { SessionJob, SessionObserveJobFrame } from '../types.ts'
-import type { SessionRemote } from './transport.ts'
-
-/** Bounded per-job render tail, in UTF-16 code units. */
-const RENDER_TAIL_LIMIT = 128 * 1024
-
-/** One observed job's live view state. */
-export interface ObservedJob {
- readonly jobId: JobId
- /** Accumulated output tail, bounded to the render limit. */
- readonly text: string
- /** True when bytes before {@link text} were dropped (eviction, resume gap, or the render bound). */
- readonly gapBefore: boolean
- /** Latest status delivered on the observation stream. */
- readonly status: SessionJob['status']
- readonly detail?: string
- /** True while the observation stream is open and the job has not settled. */
- readonly streaming: boolean
- /** Terminal observation failure, when the stream ended abnormally. */
- readonly error?: string
-}
-
-/** Immutable client job-output state. */
-export interface JobOutputSnapshot {
- /** Live observation state keyed by job id. */
- readonly observed: Readonly>
-}
-
-/** Mutable observation bookkeeping behind one {@link ObservedJob} view. */
-interface ObservedState {
- view: ObservedJob
- /** Resume offset for the next generation's `from`. */
- cursor: number | undefined
-}
-
-/**
- * Owns per-job observation state. Pure data plus subscriptions — transport
- * wiring stays in the client service, UI stays in slot components.
- */
-export class ClientJobOutputModel {
- private readonly observedStates = new Map()
- private readonly listeners = new Set<() => void>()
- private snapshotCache: JobOutputSnapshot = { observed: {} }
- private snapshotDirty = false
-
- /**
- * Read the identity-stable current snapshot.
- * @returns the cached snapshot, rebuilt only after a change.
- */
- getSnapshot(): JobOutputSnapshot {
- if (this.snapshotDirty) {
- const observed: Record = {}
- for (const [id, state] of this.observedStates) observed[id] = state.view
- this.snapshotCache = { observed }
- this.snapshotDirty = false
- }
- return this.snapshotCache
- }
-
- /**
- * Subscribe to snapshot changes.
- * @param listener - invalidation callback.
- * @returns unsubscribe function.
- */
- subscribe(listener: () => void): () => void {
- this.listeners.add(listener)
- return () => { this.listeners.delete(listener) }
- }
-
- /**
- * The resume offset for one job's next observation generation.
- * @param id - observed job.
- * @returns the last accepted `next`, or undefined for a fresh observation.
- */
- cursorOf(id: JobId): number | undefined {
- return this.observedStates.get(String(id))?.cursor
- }
-
- /**
- * Install or reset observation state when a generation's anchor arrives.
- * @param id - observed job.
- * @param frame - the generation's `opened` anchor.
- */
- observeOpened(id: JobId, frame: Extract): void {
- const existing = this.observedStates.get(String(id))
- // A fresh view anchored past offset zero starts after an evicted head
- // (fresh observations anchor at the registry's earliest retained byte), so
- // it owes the same gap mark a live observer earned from lossy reads. A
- // resume that already accumulated text keeps its recorded gap state.
- const freshPastHead = (existing === undefined || existing.view.text === '') && frame.from > 0
- const view: ObservedJob = {
- jobId: id,
- text: existing?.view.text ?? '',
- gapBefore: (existing?.view.gapBefore ?? false) || frame.from < frame.earliest || freshPastHead,
- status: frame.status,
- ...frame.detail !== undefined ? { detail: frame.detail } : {},
- streaming: true,
- }
- this.observedStates.set(String(id), { view, cursor: frame.from })
- this.changed()
- }
-
- /**
- * Append one output frame's chunks to the bounded render tail.
- * @param id - observed job.
- * @param frame - a coalesced `output` frame.
- */
- observeOutput(id: JobId, frame: Extract): void {
- const state = this.observedStates.get(String(id))
- /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
- if (state === undefined) return
- let text = state.view.text + frame.chunks.map(chunk => chunk.text).join('')
- let gapBefore = state.view.gapBefore || frame.lossy === true
- || frame.chunks.some(chunk => chunk.gapBefore === true)
- if (text.length > RENDER_TAIL_LIMIT) {
- let cut = text.length - RENDER_TAIL_LIMIT
- // Never split a surrogate pair at the render bound.
- const unit = text.charCodeAt(cut)
- if (unit >= 0xDC00 && unit <= 0xDFFF) cut += 1
- text = text.slice(cut)
- gapBefore = true
- }
- state.view = { ...state.view, text, gapBefore }
- state.cursor = frame.next
- this.changed()
- }
-
- /**
- * Record the terminal status delivered at the end of the observation stream.
- * @param id - observed job.
- * @param frame - the terminal `status` frame.
- */
- observeStatus(id: JobId, frame: Extract): void {
- const state = this.observedStates.get(String(id))
- /* v8 ignore next -- frames arrive only between opened and stop for a tracked id. */
- if (state === undefined) return
- state.view = {
- ...state.view,
- status: frame.status,
- ...frame.detail !== undefined ? { detail: frame.detail } : {},
- streaming: false,
- }
- this.changed()
- }
-
- /**
- * Record a terminal observation failure.
- * @param id - observed job.
- * @param error - the stream's terminal failure.
- */
- observeFailed(id: JobId, error: unknown): void {
- const state = this.observedStates.get(String(id))
- if (state === undefined) return
- state.view = { ...state.view, streaming: false, error: String(error) }
- this.changed()
- }
-
- /**
- * Drop observation state after the last observer stops.
- * @param id - the no-longer-observed job.
- */
- observeStopped(id: JobId): void {
- if (!this.observedStates.delete(String(id))) return
- this.changed()
- }
-
- private changed(): void {
- this.snapshotDirty = true
- notifySubscribers(this.listeners, 'job output')
- }
-}
-
-/** Bare observable source for the client job-output snapshot. */
-export interface JobOutputSource {
- /** Read the identity-stable current snapshot. */
- getSnapshot(): JobOutputSnapshot
- /**
- * Subscribe to snapshot changes.
- * @param listener - invalidation callback.
- * @returns unsubscribe function.
- */
- subscribe(listener: () => void): () => void
-}
-
-/** The job-output client service face. */
-export interface IJobOutput {
- /** Per-job observation state. */
- readonly state: JobOutputSource
- /**
- * Start observing one job's live record; reference-counted, so two viewers
- * of the same job share one stream.
- * @param sessionId - owning session used for the fenced read; undefined for an unowned job.
- * @param id - job to observe.
- * @returns stop function releasing this observer's reference.
- */
- observe(sessionId: SessionId | undefined, id: JobId): () => void
-}
-
-/** One reference-counted observation stream. */
-interface ObservationEntry {
- refs: number
- stopped: boolean
- dispose: () => Promise
-}
-
-/** Remote face the observation runner drives. */
-type JobObserveRemote = Pick & { readonly session: Pick }
-
-declare module '@deepseek-ai/cordis' {
- interface Context {
- /** React-free client job-record observation control. */
- jobOutput: IJobOutput
- }
-}
-
-/** Owns the bare job-output snapshot and per-job observation streams. */
-export class ClientJobOutput extends Service implements IJobOutput {
- readonly state: JobOutputSource
- private readonly entries = new Map()
-
- /**
- * @param ctx - client root Context.
- * @param remote - generated session namespace plus the Gateway stream factory.
- * @param model - shared client job-output model.
- */
- constructor(
- ctx: Context,
- private readonly remote: JobObserveRemote,
- private readonly model: ClientJobOutputModel,
- ) {
- super(ctx, 'jobOutput')
- this.state = model
- ctx.effect(() => () => {
- const open = [...this.entries.values()]
- this.entries.clear()
- for (const entry of open) {
- entry.stopped = true
- void entry.dispose()
- }
- }, 'session-controller.client.observations')
- }
-
- observe(sessionId: SessionId | undefined, id: JobId): () => void {
- const key = String(id)
- const existing = this.entries.get(key)
- if (existing !== undefined && !existing.stopped) {
- existing.refs += 1
- return this.releaser(key, existing)
- }
- const entry = this.startObservation(sessionId, id)
- this.entries.set(key, entry)
- return this.releaser(key, entry)
- }
-
- /**
- * Release closures bind the exact entry they were minted for, never the
- * map's current occupant: a later `observe()` on the same id may have
- * replaced a stopped entry, and decrementing or disposing through the key
- * alone would tear down that newer stream's references.
- */
- private releaser(key: string, entry: ObservationEntry): () => void {
- let released = false
- return () => {
- if (released) return
- released = true
- entry.refs -= 1
- if (entry.refs > 0) return
- if (this.entries.get(key) === entry) this.entries.delete(key)
- entry.stopped = true
- void entry.dispose().then(() => {
- // Clear the view only while no successor observation holds the key:
- // a re-expand inside the dispose round-trip already re-anchored the
- // model, and a stale clear would blank its panel for good.
- if (this.entries.has(key)) return
- // The key is the stringified branded id this releaser was minted for.
- this.model.observeStopped(key as JobId)
- })
- }
- }
-
- private startObservation(sessionId: SessionId | undefined, id: JobId): ObservationEntry {
- const name = `job observation ${String(id)}`
- const stream = this.remote.$stream({
- name,
- open: (signal) => {
- const from = this.model.cursorOf(id)
- return this.remote.session.observeJob(
- {
- jobId: id,
- ...sessionId !== undefined ? { sessionId } : {},
- ...from !== undefined ? { from } : {},
- },
- signal,
- )
- },
- // A premature end after the anchor is retryable (a Host reload closes the
- // generation); resuming from the cursor loses nothing. An end before the
- // anchor is terminal.
- ended: accepted => accepted
- ? new RemoteStreamCarrierError(`${name} ended before settlement`)
- : new Error(`${name} ended before its anchor`),
- })
- const entry: ObservationEntry = {
- refs: 1,
- stopped: false,
- dispose: () => stream.dispose(),
- }
- void (async () => {
- try {
- for await (const item of stream) {
- const frame = item.value
- if (frame.type === 'opened') {
- this.model.observeOpened(id, frame)
- item.accept()
- continue
- }
- if (frame.type === 'output') {
- this.model.observeOutput(id, frame)
- continue
- }
- // Terminal status: leave the loop before the generation end is
- // classified, then close the stream for good.
- this.model.observeStatus(id, frame)
- break
- }
- } catch (error) {
- if (!entry.stopped) this.model.observeFailed(id, error)
- } finally {
- entry.stopped = true
- void entry.dispose()
- }
- })()
- return entry
- }
-}
diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts
index b6dd8e08b5..1f8718e62f 100644
--- a/packages/api/session-controller/src/index.ts
+++ b/packages/api/session-controller/src/index.ts
@@ -2,7 +2,6 @@
import { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
-import type {} from '@deepseek-ai/dsh-jobs'
import { errorChain } from '@deepseek-ai/dsh-llm'
import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command'
import type { SessionId } from '@deepseek-ai/dsh-session'
@@ -17,7 +16,6 @@ import {
import { SessionCommandController } from './commands.ts'
import { SessionControlController } from './control.ts'
import { SessionHistoryController } from './history.ts'
-import { observeJobRecord } from './observe-job.ts'
import { SessionFileReferences } from './file-references.ts'
import {
ApiSessionList,
@@ -42,8 +40,6 @@ import type {
SessionForkValue,
SessionListRequest,
SessionListValue,
- SessionObserveJobFrame,
- SessionObserveJobRequest,
SessionOpenWorkspacePathRequest,
SessionOpenWorkspacePathValue,
SessionPage,
@@ -72,12 +68,6 @@ declare module '@deepseek-ai/cordis' {
}
}
-/** Default coalescing window between job-observation reads, in milliseconds. */
-const DEFAULT_OBSERVE_FLUSH_MS = 100
-
-/** Default soft byte budget per job-observation output frame. */
-const DEFAULT_OBSERVE_MAX_FRAME_BYTES = 64 * 1024
-
/** Session Controller deployment policy. */
export interface Config {
/** Maximum stat-reported event count eligible for one full cold projection observation; `0` disables the event-count gate. */
@@ -86,10 +76,6 @@ export interface Config {
readonly coldBlankProbeMaxBytes?: number
/** Override platform desktop-opener detection. */
readonly nativeOpen?: boolean
- /** Coalescing window after new record output before a job-observation read, in milliseconds (default 100). */
- readonly observeFlushMs?: number
- /** Soft byte budget per job-observation output frame (default 65536); one larger chunk ships whole. */
- readonly observeMaxFrameBytes?: number
}
/** Host integrations replaceable by direct unit tests. */
@@ -118,8 +104,6 @@ export class SessionController extends TypertRemoteService {
coldBlankProbeMaxEvents: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_EVENTS),
coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES),
nativeOpen: z.boolean(),
- observeFlushMs: z.natural().min(1).default(DEFAULT_OBSERVE_FLUSH_MS),
- observeMaxFrameBytes: z.natural().min(1).default(DEFAULT_OBSERVE_MAX_FRAME_BYTES),
})
private readonly agents: ApiSessionAgentController
@@ -130,8 +114,6 @@ export class SessionController extends TypertRemoteService {
private readonly openPath: (path: string, signal: AbortSignal) => Promise
private readonly canOpenPath: () => boolean
private readonly promotions = new Set>()
- private readonly observeFlushMs: number
- private readonly observeMaxFrameBytes: number
/**
* @param ctx - Host context containing the Session capability assembly.
@@ -157,8 +139,6 @@ export class SessionController extends TypertRemoteService {
this.openPath = internals.openPath ?? openNativePath
this.canOpenPath = internals.canOpenPath
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
- this.observeFlushMs = config.observeFlushMs ?? DEFAULT_OBSERVE_FLUSH_MS
- this.observeMaxFrameBytes = config.observeMaxFrameBytes ?? DEFAULT_OBSERVE_MAX_FRAME_BYTES
ctx.plugin(SessionFileReferences)
ctx.plugin(SessionSkillCatalog)
@@ -419,29 +399,6 @@ export class SessionController extends TypertRemoteService {
return this.controlState.control(signal)
}
- /**
- * Stream one job's retained record output from an absolute byte offset,
- * then its terminal status once settled and drained. Non-consuming: the
- * model-facing cursor and notice state never observe these reads. The
- * request's session resolves the fenced-read caller; the registry rejects a
- * job the session does not own, a job without a record, or an unknown job.
- * @param request - target job, owning session, and optional resume offset.
- * @param signal - cancellation owned by the Remote stream carrier.
- * @returns anchor, coalesced output frames, and the terminal status.
- */
- @Remote({ mode: 'stream' })
- observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable {
- const registry = this.ctx.get('jobs')
- if (registry === undefined) {
- throw new Error('job observation unavailable: no job registry is loaded (load @deepseek-ai/dsh-jobs-local)')
- }
- const caller = request.sessionId === undefined ? undefined : this.ctx.agents.get(request.sessionId)
- return observeJobRecord(registry, request, {
- flushMs: this.observeFlushMs,
- maxFrameBytes: this.observeMaxFrameBytes,
- caller,
- }, signal)
- }
}
diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts
index b02c69ede8..c5a6caeacb 100644
--- a/packages/api/session-controller/src/types.ts
+++ b/packages/api/session-controller/src/types.ts
@@ -491,7 +491,7 @@ export interface SessionJob {
/**
* Total UTF-8 bytes the job's observation record holds so far. Present
* exactly when the job declared a record — the row is observable through
- * `session.observeJob`.
+ * the job controller's `job.observe` stream.
*/
readonly outputTotal?: number
}
@@ -518,62 +518,7 @@ export type SessionControlFrame =
| { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] }
| ({ readonly type: 'projection' } & SessionProjectionUpdate)
-/** Opens one observation stream over a job's retained record output. */
-export interface SessionObserveJobRequest {
- /** Owning session used for the fenced read; omitted for an unowned job. */
- readonly sessionId?: SessionId
- readonly jobId: JobId
- /**
- * Absolute byte offset to resume from — a previous frame's `next`. Omitted
- * starts at the oldest retained byte.
- */
- readonly from?: number
-}
-/** One retained record chunk on the wire. `channel` widens to string like `kind`. */
-export interface SessionJobWireChunk {
- /** Absolute offset of the chunk's first byte. */
- readonly at: number
- readonly text: string
- readonly channel?: string
- /** Bytes immediately before this chunk were lost at the producer or to retention. */
- readonly gapBefore?: true
-}
-
-/**
- * Job observation stream frames: one `opened` anchor, then coalesced `output`
- * batches, then — once the job has settled and its retained record is
- * drained — one terminal `status`, after which the stream closes normally.
- * Status rides the same stream as output so settlement can never race a
- * still-open output channel.
- */
-export type SessionObserveJobFrame =
- | {
- readonly type: 'opened'
- readonly jobId: JobId
- /** Offset the first `output` frame continues from. */
- readonly from: number
- /** Oldest retained byte at open time; `from < earliest` means the head is gone. */
- readonly earliest: number
- /** Total bytes produced at open time. */
- readonly total: number
- readonly status: SessionJob['status']
- readonly detail?: string
- }
- | {
- readonly type: 'output'
- readonly chunks: readonly SessionJobWireChunk[]
- /** Offset to resume from after this frame. */
- readonly next: number
- /** Bytes between the requested offset and `chunks` were already evicted. */
- readonly lossy?: true
- }
- | {
- readonly type: 'status'
- readonly status: SessionJob['status']
- readonly detail?: string
- readonly finishedAt?: number
- }
declare module '@deepseek-ai/cordis' {
interface Events {
diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts
index b3fd854a68..07f88b1a4f 100644
--- a/packages/api/session-controller/tests/client-apply.client.spec.ts
+++ b/packages/api/session-controller/tests/client-apply.client.spec.ts
@@ -106,18 +106,6 @@ async function flush(): Promise {
}
describe('Session Controller Client apply', () => {
- it('installs ctx.jobOutput over the captured session namespace', async () => {
- const bench = await mount()
- expect(bench.ctx.jobOutput).toBeDefined()
- // The fake observeJob never yields; opening and releasing must leave no
- // view and no error behind.
- const stop = bench.ctx.jobOutput.observe(sid('session-1'), 'bash-1' as never)
- await flush()
- stop()
- await flush()
- expect(bench.ctx.jobOutput.state.getSnapshot().observed).toEqual({})
- })
-
it('routes Session Remote Events and connection generations into the object layer', async () => {
const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected')
const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError')
diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts
index e67650d4a2..e3ea48f783 100644
--- a/packages/api/session-controller/tests/fake-api.client.ts
+++ b/packages/api/session-controller/tests/fake-api.client.ts
@@ -10,7 +10,7 @@ import type {
import type {
SessionAddress,
SessionControlBaseline,
- SessionControlFrame, SessionObserveJobFrame,
+ SessionControlFrame,
SessionFollowFrame,
SessionFollowRequest,
SessionPage,
@@ -235,9 +235,6 @@ export class FakeApiClient {
page: request => this.page(request),
follow: (request, signal) => this.openFollow(request, signal),
control: signal => this.openControl(signal),
- // Job-record observation never yields here; the stream consumer is
- // exercised directly by job-output.client.spec.
- observeJob: () => (async function* (): AsyncGenerator {})(),
},
subagents: {
list: parentSessionId => this.record(
diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts
index be09dd8d80..1b5859b509 100644
--- a/packages/api/session-controller/tests/test-remote.ts
+++ b/packages/api/session-controller/tests/test-remote.ts
@@ -40,8 +40,6 @@ import type {
SessionFollowRequest,
SessionListRequest,
SessionListValue,
- SessionObserveJobFrame,
- SessionObserveJobRequest,
SessionOpenWorkspacePathRequest,
SessionOpenWorkspacePathValue,
SessionPage,
@@ -79,7 +77,6 @@ export interface TestSessionRemote {
page(request: SessionPageRequest, signal?: AbortSignal): Promise>
follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable
control(signal?: AbortSignal): AsyncIterable
- observeJob(request: SessionObserveJobRequest, signal?: AbortSignal): AsyncIterable
}
/** Dependencies and policy supplied by a Session Controller unit harness. */
@@ -332,6 +329,5 @@ export function createSessionTestRemote(
),
follow: (request, signal = new AbortController().signal) => direct.follow(request, signal),
control: (signal = new AbortController().signal) => direct.control(signal),
- observeJob: (request, signal = new AbortController().signal) => direct.observeJob(request, signal),
}
}
diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json
index 086ab4a66f..d13a698f67 100644
--- a/packages/api/session-controller/tsconfig.host.json
+++ b/packages/api/session-controller/tsconfig.host.json
@@ -17,34 +17,83 @@
"src/history.ts",
"src/list.ts",
"src/model-selection-projection.ts",
- "src/observe-job.ts",
"src/skill-catalog.ts"
],
"references": [
- { "path": "../../../vendor/cordis" },
- { "path": "../../../vendor/schemastery" },
- { "path": "../../core/agent" },
- { "path": "../../core/agent-default-model" },
- { "path": "../../core/scope" },
- { "path": "../../core/session" },
- { "path": "../../context/file-reference" },
- { "path": "../../attachment/attachment" },
- { "path": "../../interaction/permission-presets" },
- { "path": "../../jobs/jobs" },
- { "path": "../../llm/llm" },
- { "path": "../../util/deque" },
- { "path": "../../util/native-command" },
- { "path": "../../preset/agent-presets" },
- { "path": "../../session/session-persistence" },
- { "path": "../../session/session-projection" },
- { "path": "../../session/session-projection-cache" },
- { "path": "../../session/session-title" },
- { "path": "../../session-query/session-query" },
- { "path": "../../skill/skill" },
- { "path": "../../subagent/subagent" },
- { "path": "../../util/time" },
- { "path": "../../typert/protocol" },
- { "path": "../../typert/registry" },
- { "path": "../../workspace/workspace" }
+ {
+ "path": "../../../vendor/cordis"
+ },
+ {
+ "path": "../../../vendor/schemastery"
+ },
+ {
+ "path": "../../core/agent"
+ },
+ {
+ "path": "../../core/agent-default-model"
+ },
+ {
+ "path": "../../core/scope"
+ },
+ {
+ "path": "../../core/session"
+ },
+ {
+ "path": "../../context/file-reference"
+ },
+ {
+ "path": "../../attachment/attachment"
+ },
+ {
+ "path": "../../interaction/permission-presets"
+ },
+ {
+ "path": "../../jobs/jobs"
+ },
+ {
+ "path": "../../llm/llm"
+ },
+ {
+ "path": "../../util/deque"
+ },
+ {
+ "path": "../../util/native-command"
+ },
+ {
+ "path": "../../preset/agent-presets"
+ },
+ {
+ "path": "../../session/session-persistence"
+ },
+ {
+ "path": "../../session/session-projection"
+ },
+ {
+ "path": "../../session/session-projection-cache"
+ },
+ {
+ "path": "../../session/session-title"
+ },
+ {
+ "path": "../../session-query/session-query"
+ },
+ {
+ "path": "../../skill/skill"
+ },
+ {
+ "path": "../../subagent/subagent"
+ },
+ {
+ "path": "../../util/time"
+ },
+ {
+ "path": "../../typert/protocol"
+ },
+ {
+ "path": "../../typert/registry"
+ },
+ {
+ "path": "../../workspace/workspace"
+ }
]
}
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml
index ef7539a7be..e7c42d038a 100644
--- a/packages/bundle/web-app/cordis.patch.yml
+++ b/packages/bundle/web-app/cordis.patch.yml
@@ -91,6 +91,11 @@
- id: session-controller
name: '@deepseek-ai/dsh-api-session-controller'
+ # One background job's observation record streamed over Typert Remote;
+ # the roster rides the session control stream above.
+ - id: job-controller
+ name: '@deepseek-ai/dsh-api-job-controller'
+
# Configuration-surface reads and writes over Typert Remote. Each method
# reports an actionable error when its settings-domain provider is absent.
- id: settings-controller
diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json
index 63f32d737e..5d8ce4524d 100644
--- a/packages/bundle/web-app/package.json
+++ b/packages/bundle/web-app/package.json
@@ -40,73 +40,74 @@
},
"dependencies": {
"@deepseek-ai/dsh-agent-presets": "workspace:^",
- "@deepseek-ai/dsh-tool-subagent": "workspace:^",
+ "@deepseek-ai/dsh-api-job-controller": "workspace:^",
"@deepseek-ai/dsh-api-remotes": "workspace:^",
+ "@deepseek-ai/dsh-api-session-controller": "workspace:^",
+ "@deepseek-ai/dsh-api-settings-controller": "workspace:^",
+ "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-app-boot": "workspace:^",
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-hmr": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-modules": "workspace:^",
- "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
- "@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-approval": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-attachment": "workspace:^",
"@deepseek-ai/dsh-client-ui-brand-official": "workspace:^",
- "@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-chat": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-commands": "workspace:^",
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
"@deepseek-ai/dsh-client-ui-cordis": "workspace:^",
"@deepseek-ai/dsh-client-ui-deliverables": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-client-ui-directory-picker-native": "workspace:^",
- "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^",
"@deepseek-ai/dsh-client-ui-goal": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-jobs": "workspace:^",
"@deepseek-ai/dsh-client-ui-layout": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-message-feedback": "workspace:^",
"@deepseek-ai/dsh-client-ui-model-selection": "workspace:^",
- "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
- "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^",
"@deepseek-ai/dsh-client-ui-plan": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-reference": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-schedule": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
- "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
- "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings": "workspace:^",
"@deepseek-ai/dsh-client-ui-settings-general": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-settings-models": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^",
"@deepseek-ai/dsh-client-ui-sidebar": "workspace:^",
"@deepseek-ai/dsh-client-ui-skill": "workspace:^",
- "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^",
- "@deepseek-ai/dsh-client-ui-reference": "workspace:^",
"@deepseek-ai/dsh-client-ui-subagent": "workspace:^",
- "@deepseek-ai/dsh-client-ui-jobs": "workspace:^",
"@deepseek-ai/dsh-client-ui-theme": "workspace:^",
"@deepseek-ai/dsh-client-ui-tool": "workspace:^",
- "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^",
"@deepseek-ai/dsh-client-ui-trajectory": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^",
+ "@deepseek-ai/dsh-client-ui-workflow-run": "workspace:^",
"@deepseek-ai/dsh-client-ui-workspace": "workspace:^",
"@deepseek-ai/dsh-cmdline": "workspace:^",
"@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:^",
"@deepseek-ai/dsh-cordis-client-runner": "workspace:^",
"@deepseek-ai/dsh-cordis-host-runner": "workspace:^",
- "@deepseek-ai/dsh-web-frontend": "workspace:^",
- "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
+ "@deepseek-ai/dsh-file-reference": "workspace:^",
+ "@deepseek-ai/dsh-file-reference-local": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-auto": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^",
"@deepseek-ai/dsh-host-directory-picker-native": "workspace:^",
+ "@deepseek-ai/dsh-host-frontend-static": "workspace:^",
"@deepseek-ai/dsh-host-plugin-inventory": "workspace:^",
"@deepseek-ai/dsh-host-webserver": "workspace:^",
- "@deepseek-ai/dsh-file-reference": "workspace:^",
- "@deepseek-ai/dsh-file-reference-local": "workspace:^",
"@deepseek-ai/dsh-launch-environment": "workspace:^",
"@deepseek-ai/dsh-message-feedback": "workspace:^",
- "@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-log-export": "workspace:^",
+ "@deepseek-ai/dsh-session-reference": "workspace:^",
"@deepseek-ai/dsh-session-stats": "workspace:^",
"@deepseek-ai/dsh-session-turn-outline": "workspace:^",
- "@deepseek-ai/dsh-api-session-controller": "workspace:^",
- "@deepseek-ai/dsh-api-settings-controller": "workspace:^",
- "@deepseek-ai/dsh-api-workspace-controller": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
+ "@deepseek-ai/dsh-tool-subagent": "workspace:^",
+ "@deepseek-ai/dsh-web-frontend": "workspace:^",
"@deepseek-ai/dsh-workspace": "workspace:^",
"@deepseek-ai/schemastery": "workspace:^",
"commander": "^15.0.0",
diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts
index 12557c1c73..eb2ed7cf83 100644
--- a/packages/client/ui-chat/src/client/contract/slots.ts
+++ b/packages/client/ui-chat/src/client/contract/slots.ts
@@ -2,8 +2,8 @@
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
import type { SessionSeq } from '@deepseek-ai/dsh-session/types'
import type {
- ConversationLocationDataStore, ConversationTurnDataMap,
- MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, TurnLocation,
+ CommandNode, CompactionSummaryNode, ConversationLocationDataStore, ConversationTurnDataMap,
+ MessageImageLoader, MessageImagesOwnerProps, RenderMessageImages, ToolCallBlock, TurnLocation,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type {
InjectFace, KeyedSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore,
@@ -16,8 +16,7 @@ import type { createChatStore } from '../stores.ts'
import type { ToolCallId, SelectionTarget } from './store.ts'
import type { ChatConversationViewNode, ChatNode, ChatNodeKind } from './chat-nodes.ts'
import type {
- ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation, CommandNode,
- CompactionSummaryNode, ToolCallBlock,
+ ChatNodeProcessSource, ChatNodeSource, ChatSnapshot, ChatTurnProcessPresentation,
} from './snapshot.ts'
import type { TurnProcessSpec } from './turn-process.ts'
import type { TranscriptViewMode } from '../../chat-settings.ts'
diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
index 6fa7671da6..64d74f28d8 100644
--- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
+++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts
@@ -1,15 +1,14 @@
import type { Context } from '@deepseek-ai/cordis'
import { notifySubscribers } from '@deepseek-ai/dsh-client-store'
import type {
- ConversationLocation, ConversationTimelineSnapshot, ConversationViewBuilder,
- ConversationViewDefinition,
+ ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder,
+ ConversationViewDefinition, PartialAssistant, RunningToolCall,
} from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts'
import { isRunningTool } from '../contract/chat-nodes.ts'
import type {
ChatLocationNodeIndex, ChatNodeProcessSource, ChatNodeSource, ChatNodeStore, ChatSnapshot,
- ChatTurnNavigationIndex, ChatTurnProcessPresentation, ConversationNode, LegacyConversationSlice,
- PartialAssistant, RunningToolCall, TurnNavigationItem,
+ ChatTurnNavigationIndex, ChatTurnProcessPresentation, LegacyConversationSlice, TurnNavigationItem,
} from '../contract/snapshot.ts'
import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts'
import { sessionRecallLabels } from './event-projection.ts'
diff --git a/packages/client/ui-jobs/README.i18n.yaml b/packages/client/ui-jobs/README.i18n.yaml
index b12c1d3926..fe508b8379 100644
--- a/packages/client/ui-jobs/README.i18n.yaml
+++ b/packages/client/ui-jobs/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md
-README.md: 51e6916ed4a72fb8f618154bbd2ec156ad0af2bc
-README.zh.md: f21e097e7860def07663376bf5de0f9d565a34c0
+README.md: a0ecc137ab1eb57aeaea9ccdfdbaf898794de2fe
+README.zh.md: 744450c480b4d26c0558d992904817174ca2ed73
diff --git a/packages/client/ui-jobs/README.md b/packages/client/ui-jobs/README.md
index 51e6916ed4..a0ecc137ab 100644
--- a/packages/client/ui-jobs/README.md
+++ b/packages/client/ui-jobs/README.md
@@ -33,7 +33,7 @@ The session control stream's `jobsBySession` mirror is the single roster: each `
### The expanded panel
-Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel.
+Expanding an observable row opens that job's record observation stream from `ctx.jobOutput` (installed by `dsh-api-job-controller`) into an embedded terminal panel. The panel copies the command (not the output), wraps commands and output lines in full, scrolls its output inside a fixed height instead of folding, and draws no run-state dot of its own — the row above carries the state. Retention gaps and stream failures render as notices above the panel.
-----
@@ -58,7 +58,8 @@ One slot entry in the header actions band (after the preset label, before the su
## Further Exploration
-- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` mirror and the `session.observeJob` stream behind the panel.
+- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.observe` stream and the `ctx.jobOutput` service behind the panel.
+- [`dsh-api-session-controller`](../../api/session-controller/README.md) — the `jobsBySession` roster mirror the rows come from.
- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the record semantics.
- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures.
diff --git a/packages/client/ui-jobs/README.zh.md b/packages/client/ui-jobs/README.zh.md
index f21e097e78..744450c480 100644
--- a/packages/client/ui-jobs/README.zh.md
+++ b/packages/client/ui-jobs/README.zh.md
@@ -33,7 +33,7 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo
### 展开的面板
-展开可观察的行会从 `ctx.jobOutput` 打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。
+展开可观察的行会从 `ctx.jobOutput`(由 `dsh-api-job-controller` 安装)打开该 job 的 record 观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。
-----
@@ -58,7 +58,8 @@ session control 流的 `jobsBySession` 镜像是唯一名册:每个 `SessionJo
## 进一步探索
-- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 面板背后的 `jobsBySession` 镜像与 `session.observeJob` 流。
+- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 面板背后的 `job.observe` 流与 `ctx.jobOutput` 服务。
+- [`dsh-api-session-controller`](../../api/session-controller/README.zh.md) —— 行所来自的 `jobsBySession` 名册镜像。
- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有 record 语义的注册表契约。
- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。
diff --git a/packages/client/ui-jobs/package.json b/packages/client/ui-jobs/package.json
index 885effb30c..fb85ed298d 100644
--- a/packages/client/ui-jobs/package.json
+++ b/packages/client/ui-jobs/package.json
@@ -35,6 +35,7 @@
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:^",
+ "@deepseek-ai/dsh-api-job-controller": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -55,6 +56,7 @@
"dsh": {
"client": {
"inject": [
+ "@deepseek-ai/dsh-api-job-controller",
"@deepseek-ai/dsh-api-session-controller",
"@deepseek-ai/dsh-client-locale",
"@deepseek-ai/dsh-client-ui-conversation",
diff --git a/packages/client/ui-jobs/src/client/JobListAction.tsx b/packages/client/ui-jobs/src/client/JobListAction.tsx
index edac6497f6..87acb18a32 100644
--- a/packages/client/ui-jobs/src/client/JobListAction.tsx
+++ b/packages/client/ui-jobs/src/client/JobListAction.tsx
@@ -1,5 +1,5 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react'
-import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client'
import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
diff --git a/packages/client/ui-jobs/src/client/index.ts b/packages/client/ui-jobs/src/client/index.ts
index 164b6f6e3c..209cec1f23 100644
--- a/packages/client/ui-jobs/src/client/index.ts
+++ b/packages/client/ui-jobs/src/client/index.ts
@@ -8,6 +8,7 @@
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import { JobListAction } from './JobListAction.tsx'
import type { JobListInjected } from './JobListAction.tsx'
+import type {} from '@deepseek-ai/dsh-api-job-controller/client'
import type {} from '@deepseek-ai/dsh-api-session-controller/client'
import type {} from '@deepseek-ai/dsh-client-locale/client'
import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'
diff --git a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
index 15bd8ec4a9..9d659ca2ae 100644
--- a/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
+++ b/packages/client/ui-jobs/tests/job-list-action.client.spec.tsx
@@ -2,7 +2,7 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
-import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-session-controller/client'
+import type { JobOutputSnapshot, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client'
import type { SessionJob } from '@deepseek-ai/dsh-api-session-controller/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { JobListAction, type JobListActionProps } from '../src/client/JobListAction.tsx'
diff --git a/packages/client/ui-jobs/tsconfig.json b/packages/client/ui-jobs/tsconfig.json
index 0e66aa4958..38756e4d88 100644
--- a/packages/client/ui-jobs/tsconfig.json
+++ b/packages/client/ui-jobs/tsconfig.json
@@ -8,6 +8,9 @@
"src"
],
"references": [
+ {
+ "path": "../../api/job-controller/tsconfig.client.json"
+ },
{
"path": "../../api/session-controller/tsconfig.client.json"
},
diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
index fa02cba58f..0daf030fdd 100644
--- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
+++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts
@@ -204,7 +204,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:228',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:227',
},
{
key: 'conversation.chat.commandview',
@@ -249,7 +249,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
occupants: [],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:216',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:215',
},
{
key: 'conversation.chat.node',
@@ -314,7 +314,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:197',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:196',
},
{
key: 'conversation.chat.turnTail',
@@ -359,7 +359,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'none',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', select: owner => null },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:222',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:221',
},
{
key: 'conversation.composer',
@@ -533,7 +533,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.details.tool\', () => ctx.slots.register(\n { name: \'conversation.details.tool\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:234',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:233',
},
{
key: 'conversation.hero.agentPreset',
@@ -1011,7 +1011,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [
],
replaceRisk: 'shadows-shipped-ui',
example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}',
- source: 'packages/client/ui-chat/src/client/contract/slots.ts:210',
+ source: 'packages/client/ui-chat/src/client/contract/slots.ts:209',
},
{
key: 'conversation.session',
diff --git a/packages/extensions/tool-cordis/src/api-catalog.ts b/packages/extensions/tool-cordis/src/api-catalog.ts
index 2d81832d71..9de2adb4c9 100644
--- a/packages/extensions/tool-cordis/src/api-catalog.ts
+++ b/packages/extensions/tool-cordis/src/api-catalog.ts
@@ -1033,6 +1033,19 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
+ {
+ key: 'jobController',
+ summary: 'Host service backing the generated `ctx.remote.job` namespace.',
+ description: 'Host service backing the generated `ctx.remote.job` namespace.',
+ methods: [
+ {
+ signature: '@Remote({ mode: \'stream\' }) observe(request: JobObserveRequest, signal: AbortSignal): AsyncIterable',
+ description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.',
+ parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }],
+ returns: 'anchor, coalesced output frames, and the terminal status.',
+ },
+ ],
+ },
{
key: 'jobs',
summary: 'Abstract background job registry.',
@@ -1455,12 +1468,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }],
returns: 'one complete baseline followed by live replacement frames.',
},
- {
- signature: '@Remote({ mode: \'stream\' }) observeJob(request: SessionObserveJobRequest, signal: AbortSignal): AsyncIterable',
- description: 'Stream one job\'s retained record output from an absolute byte offset, then its terminal status once settled and drained. Non-consuming: the model-facing cursor and notice state never observe these reads. The request\'s session resolves the fenced-read caller; the registry rejects a job the session does not own, a job without a record, or an unknown job.',
- parameters: [{ name: 'request', description: 'target job, owning session, and optional resume offset.' }, { name: 'signal', description: 'cancellation owned by the Remote stream carrier.' }],
- returns: 'anchor, coalesced output frames, and the terminal status.',
- },
],
},
{
@@ -4190,6 +4197,18 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'JobKindMap',
declaration: 'export interface JobKindMap {\n bash: \'bash\';\n subagent: \'subagent\';\n}',
},
+ {
+ name: 'JobObserveFrame',
+ declaration: 'export type JobObserveFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: JobObserveStatus;\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly JobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: JobObserveStatus;\n readonly detail?: string;\n readonly finishedAt?: number;\n};',
+ },
+ {
+ name: 'JobObserveRequest',
+ declaration: 'export interface JobObserveRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}',
+ },
+ {
+ name: 'JobObserveStatus',
+ declaration: 'export type JobObserveStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
+ },
{
name: 'JobOutcome',
declaration: 'export interface JobOutcome {\n status: \'completed\' | \'killed\' | \'failed\';\n detail?: string;\n output?: string;\n}',
@@ -4226,6 +4245,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'JobStatus',
declaration: 'export type JobStatus = \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';',
},
+ {
+ name: 'JobWireChunk',
+ declaration: 'export interface JobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}',
+ },
{
name: 'JsonSchemaNode',
declaration: 'export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n}',
@@ -4966,10 +4989,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionJob',
declaration: 'export interface SessionJob {\n readonly id: JobId;\n readonly kind: string;\n readonly label: string;\n readonly status: \'running\' | \'stopping\' | \'completed\' | \'killed\' | \'failed\';\n readonly detail?: string;\n readonly startedAt: number;\n readonly finishedAt?: number;\n readonly outputTotal?: number;\n}',
},
- {
- name: 'SessionJobWireChunk',
- declaration: 'export interface SessionJobWireChunk {\n readonly at: number;\n readonly text: string;\n readonly channel?: string;\n readonly gapBefore?: true;\n}',
- },
{
name: 'SessionLineageNode',
declaration: 'export interface SessionLineageNode {\n session: SessionRecord;\n descendants: SessionLineageNode[];\n}',
@@ -5002,14 +5021,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionObservationOptions',
declaration: 'export interface SessionObservationOptions {\n readonly signal?: AbortSignal;\n readonly projectionMode?: \'all\' | \'none\';\n}',
},
- {
- name: 'SessionObserveJobFrame',
- declaration: 'export type SessionObserveJobFrame = {\n readonly type: \'opened\';\n readonly jobId: JobId;\n readonly from: number;\n readonly earliest: number;\n readonly total: number;\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n} | {\n readonly type: \'output\';\n readonly chunks: readonly SessionJobWireChunk[];\n readonly next: number;\n readonly lossy?: true;\n} | {\n readonly type: \'status\';\n readonly status: SessionJob[\'status\'];\n readonly detail?: string;\n readonly finishedAt?: number;\n};',
- },
- {
- name: 'SessionObserveJobRequest',
- declaration: 'export interface SessionObserveJobRequest {\n readonly sessionId?: SessionId;\n readonly jobId: JobId;\n readonly from?: number;\n}',
- },
{
name: 'SessionOpenWorkspacePathRequest',
declaration: 'export interface SessionOpenWorkspacePathRequest {\n readonly path: string;\n}',
diff --git a/packages/jobs/jobs/src/pump.ts b/packages/jobs/jobs/src/pump.ts
index 8f56b6abc6..3d7d252d24 100644
--- a/packages/jobs/jobs/src/pump.ts
+++ b/packages/jobs/jobs/src/pump.ts
@@ -17,7 +17,19 @@ export interface JobPumpRead {
lossy: boolean
}
-/** One producer-owned stream the pump copies into a job record. */
+/**
+ * One producer-owned stream the pump copies into a job record.
+ *
+ * The source is a pull-by-offset snapshot reader, not a Node.js `Readable`,
+ * because the substrate it reads (the subprocess `readFrom` family) is a
+ * retained ring that several readers share: the model-facing consuming
+ * cursor, the foreground result collector, and this record pump each hold
+ * their own absolute offset and read without disturbing the others. A
+ * `Readable` is single-consumer and consuming, so wrapping the ring in one
+ * would need a tee per reader and could not express `lossy` — the reader's
+ * offset having slid out of the retained window — which the record turns
+ * into a visible `gapBefore` instead of a silent splice.
+ */
export interface JobPumpSource {
/** Stream label attached to every chunk this source yields. */
channel?: JobChannel
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e03d24425c..181c30268c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -665,6 +665,46 @@ importers:
specifier: ^4.4.3
version: 4.4.3
+ packages/api/job-controller:
+ dependencies:
+ '@deepseek-ai/dsh-typert-protocol':
+ specifier: workspace:^
+ version: link:../../typert/protocol
+ '@deepseek-ai/schemastery':
+ specifier: link:../../../vendor/schemastery
+ version: link:../../../vendor/schemastery
+ zod:
+ specifier: ^4.4.3
+ version: 4.4.3
+ devDependencies:
+ '@deepseek-ai/cordis':
+ specifier: workspace:^
+ version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-agent':
+ specifier: workspace:^
+ version: link:../../core/agent
+ '@deepseek-ai/dsh-api-gateway':
+ specifier: workspace:^
+ version: link:../gateway
+ '@deepseek-ai/dsh-client-connection':
+ specifier: workspace:^
+ version: link:../../client/connection
+ '@deepseek-ai/dsh-client-store':
+ specifier: workspace:^
+ version: link:../../client/store
+ '@deepseek-ai/dsh-jobs':
+ specifier: workspace:^
+ version: link:../../jobs/jobs
+ '@deepseek-ai/dsh-jobs-local':
+ specifier: workspace:^
+ version: link:../../jobs/jobs-local
+ '@deepseek-ai/dsh-session':
+ specifier: workspace:^
+ version: link:../../core/session
+ '@deepseek-ai/dsh-typert-registry':
+ specifier: workspace:^
+ version: link:../../typert/registry
+
packages/api/remotes:
dependencies:
'@deepseek-ai/dsh-deque':
@@ -686,6 +726,9 @@ importers:
'@deepseek-ai/dsh-api-gateway':
specifier: workspace:^
version: link:../gateway
+ '@deepseek-ai/dsh-api-job-controller':
+ specifier: workspace:^
+ version: link:../job-controller
'@deepseek-ai/dsh-api-session-controller':
specifier: workspace:^
version: link:../session-controller
@@ -1446,6 +1489,9 @@ importers:
'@deepseek-ai/dsh-agent-presets':
specifier: workspace:^
version: link:../../preset/agent-presets
+ '@deepseek-ai/dsh-api-job-controller':
+ specifier: workspace:^
+ version: link:../../api/job-controller
'@deepseek-ai/dsh-api-remotes':
specifier: workspace:^
version: link:../../api/remotes
@@ -2507,6 +2553,9 @@ importers:
'@deepseek-ai/cordis':
specifier: workspace:^
version: link:../../../vendor/cordis
+ '@deepseek-ai/dsh-api-job-controller':
+ specifier: workspace:^
+ version: link:../../api/job-controller
'@deepseek-ai/dsh-api-session-controller':
specifier: workspace:^
version: link:../../api/session-controller
diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts
index 4dde46a13a..ac52455c6d 100644
--- a/scripts/gen-cordis-catalog.ts
+++ b/scripts/gen-cordis-catalog.ts
@@ -109,6 +109,7 @@ export const SERVICE_PAGE: Record = {
subprocess: 'subprocess.md',
systemPrompt: 'system-prompt.md',
jobs: 'jobs.md',
+ jobController: 'jobs.md',
sessionTelemetry: 'session-telemetry.md',
agentTeams: 'agent-team.md',
tokenMeter: 'token-meter.md',
@@ -173,7 +174,7 @@ export const SERVICE_WALK_EXEMPTIONS: Record = {
slots: 'client-side interface-typed browser service — packages/client/ui-renderer/README.md owns the API',
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the API',
workspaces: 'client-side interface-typed browser service — packages/api/workspace-controller/README.md owns the API',
- jobOutput: 'client-side interface-typed browser service — packages/api/session-controller/README.md owns the API',
+ jobOutput: 'client-side interface-typed browser service — packages/api/job-controller/README.md owns the API',
}
/**
@@ -324,8 +325,6 @@ export const LINK_MAP: Readonly> = {
OptionalSessionSeq: 'session.md',
SessionListRequest: 'session.md',
SessionListValue: 'session.md',
- SessionObserveJobFrame: 'session.md',
- SessionObserveJobRequest: 'session.md',
ModelCatalog: 'session.md',
SessionOpenWorkspacePathRequest: 'session.md',
SessionOpenWorkspacePathValue: 'session.md',
@@ -508,7 +507,10 @@ export const LINK_MAP: Readonly> = {
ToolProviderResult: 'system-prompt.md',
JobDoneListener: 'jobs.md',
JobId: 'jobs.md',
+ JobObserveFrame: 'jobs.md',
+ JobObserveRequest: 'jobs.md',
JobRead: 'jobs.md',
+ JobWireChunk: 'jobs.md',
JobSnapshot: 'jobs.md',
JobStart: 'jobs.md',
JobsChangedListener: 'jobs.md',
diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts
index f97eb086ee..43ccb69a51 100644
--- a/scripts/gen-doc-graphs.ts
+++ b/scripts/gen-doc-graphs.ts
@@ -170,6 +170,13 @@ const SERVICE_ROLES: ServiceRole[] = [
mode: 'core',
note: 'Lists the Session composition\'s user-invocable skills without activating a cold Agent.',
},
+ {
+ key: 'jobController',
+ pkg: 'api-job-controller',
+ title: 'Host job Remote controller',
+ mode: 'core',
+ note: 'Streams one background job\'s observation record over the generated Remote namespace; the roster stays on the session control stream.',
+ },
{
key: 'credentialsController',
pkg: 'api-settings-controller',
@@ -576,7 +583,7 @@ const SERVICE_ROLES: ServiceRole[] = [
title: 'Background job registry',
mode: 'seam',
implementations: ['jobs-local'],
- consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs'],
+ consumers: ['tool-bash', 'tool-pwsh', 'tool-terminal', 'tool-subagent', 'tool-jobs', 'api-job-controller'],
note: '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.',
},
{
diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts
index 1cd954a9ca..9bb2cc7956 100644
--- a/scripts/verify-package-readme-model-experience.ts
+++ b/scripts/verify-package-readme-model-experience.ts
@@ -165,6 +165,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = {
'packages/test-support/llm-mock-server': { kind: 'none', reason: 'The test server substitutes provider wire behavior without invoking a real model.' },
'packages/test-support/llm-replay': { kind: 'none', reason: 'The keyless adapter invokes no provider model.' },
'packages/api/gateway': { kind: 'none', reason: 'Remote dispatch infrastructure; invoked business methods own any model-visible effect.' },
+ 'packages/api/job-controller': { kind: 'none', reason: 'Job observation is browser and Host control state; it registers no prompt, tool, or session event.' },
'packages/api/session-controller': { kind: 'none', reason: 'Session API and transport owner; invoked Agent commands own any model-visible effect.' },
'packages/api/settings-controller': { kind: 'none', reason: 'Configuration-surface API owner; it registers no prompt, tool, or session event.' },
'packages/api/workspace-controller': { kind: 'none', reason: 'Workspace API and state projection owner; it registers no prompt, tool, or session event.' },
diff --git a/tsconfig.base.json b/tsconfig.base.json
index 0510427c3b..fc8d7e4e0f 100644
--- a/tsconfig.base.json
+++ b/tsconfig.base.json
@@ -45,6 +45,9 @@
"@deepseek-ai/dsh-api-gateway": ["./packages/api/gateway/src/index.ts"],
"@deepseek-ai/dsh-api-gateway/client": ["./packages/api/gateway/src/client/index.ts"],
"@deepseek-ai/dsh-api-gateway/types": ["./packages/api/gateway/src/types.ts"],
+ "@deepseek-ai/dsh-api-job-controller": ["./packages/api/job-controller/src/index.ts"],
+ "@deepseek-ai/dsh-api-job-controller/client": ["./packages/api/job-controller/src/client/index.ts"],
+ "@deepseek-ai/dsh-api-job-controller/types": ["./packages/api/job-controller/src/types.ts"],
"@deepseek-ai/dsh-api-session-controller": ["./packages/api/session-controller/src/index.ts"],
"@deepseek-ai/dsh-api-session-controller/client": ["./packages/api/session-controller/src/client/index.ts"],
"@deepseek-ai/dsh-api-session-controller/types": ["./packages/api/session-controller/src/types.ts"],
diff --git a/tsconfig.client.json b/tsconfig.client.json
index bb6735bbc7..cc2c7ac7fd 100644
--- a/tsconfig.client.json
+++ b/tsconfig.client.json
@@ -58,6 +58,7 @@
{ "path": "./packages/api/gateway/tsconfig.client.json" },
{ "path": "./packages/api/session-controller/tsconfig.client.json" },
{ "path": "./packages/api/workspace-controller/tsconfig.client.json" },
+ { "path": "./packages/api/job-controller/tsconfig.client.json" },
{ "path": "./packages/api/remotes/tsconfig.client.json" },
{ "path": "./packages/extensions/cordis-client-runner" },
{ "path": "./packages/extensions/ui-cordis" },
diff --git a/tsconfig.host.json b/tsconfig.host.json
index c3c119a282..f8ae95c3d2 100644
--- a/tsconfig.host.json
+++ b/tsconfig.host.json
@@ -151,6 +151,7 @@
{ "path": "./packages/typert/registry" },
{ "path": "./packages/api/gateway/tsconfig.host.json" },
{ "path": "./packages/api/remotes/tsconfig.host.json" },
+ { "path": "./packages/api/job-controller/tsconfig.host.json" },
{ "path": "./packages/api/session-controller/tsconfig.host.json" },
{ "path": "./packages/api/settings-controller" },
{ "path": "./packages/api/workspace-controller/tsconfig.host.json" },