Merge remote-tracking branch 'origin/master' into feat/plugin-detail-slots

This commit is contained in:
Yichen Jiang committed 2026-09-21 17:15:10 +08:00
commit aba8b3a29f
259 files changed
+6650 -1951

No files matched your search

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-web-background-job-display.md
2026-08-08-web-background-job-display.md: 049785e9cd5dea2d92d9fc5a14dfeddac9b64469
2026-08-08-web-background-job-display.zh.md: 1dd7ee9215fa034a0a7abbe75bd5f6eb6907e9ee
2026-08-08-web-background-job-display.md: 1ebd600e4d423f95ad663c333572a990d0c9efa7
2026-08-08-web-background-job-display.zh.md: 56d062b5079e15c90cffc30b538efe162bc19d9d
@@ -99,7 +99,7 @@ A running one-shot background subagent therefore appears both there and in the s
**No web path calls `ctx.jobs.read()`.** It consumes the single output cursor, so a browser read would silently take bytes the model's `job_output` will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site.
**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running.
**No cancellation.** That phase owed a decision the seam then did not answer: `kill()` marked terminal delivery reported, so a human interrupt written against the `kill()` contract would leave the model believing its task is still running. The [human job kill note](2026-08-26-human-job-kill.md) later resolved it: `kill` now takes an explicit `reported` claim, and the web stop control passes `reported: false` so the completion notice stays due.
**No output watermark on the frame.** The output phase's delta channel is where an anchor field earns its place; one added now would have no reader.
@@ -133,7 +133,7 @@ Below it, [`jobs-local`](../../../../packages/jobs/jobs-local/tests/jobs.spec.ts
**Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint.
**`stopping` is rarely visible.** Only the model's `job_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change.
**`stopping` is visible on every kill path.** The model's `job_kill` and the web stop control both produce it; carrying it in the union from the start is what kept the human-kill phase off the wire format.
**Two entry points for one running subagent.** Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind.
@@ -99,7 +99,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
**没有任何 Web 路径调用 `ctx.jobs.read()`。** 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 `job_output` 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。
**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。
**不做中断。** 那一期欠一个 seam 当时没有回答的决策:`kill()` 会把终态投递标为已上报,所以照 `kill()` 契约写出来的人类中断,会让模型一直以为它的任务还在跑。[人工任务终止 Note](2026-08-26-human-job-kill.zh.md) 后来解决了它:`kill` 现在接受显式的 `reported` 认领,Web 停止控件传 `reported: false`,完成通知因此仍会投递。
**帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。
@@ -133,7 +133,7 @@ abstract onJobsChanged(listener: JobsChangedListener): () => void
**终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。
**`stopping` 很少可见。** 只有模型的 `job_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。
**`stopping` 在每条 kill 路径上都可见。** 模型的 `job_kill` 与 Web 停止控件都会产生它;从一开始就把它放进联合类型,正是人工 kill 这一期不必动线路格式的原因。
**一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-human-job-kill.md
2026-08-26-human-job-kill.md: efffdbd85bf2fa665565ff584a6938cfee827516
2026-08-26-human-job-kill.zh.md: 206acca008518dd689351e40a99ff1571f3d1a8f
@@ -0,0 +1,36 @@
# Agent Note: Human job kill — an unclaimed terminal report instead of a second cancellation path
Status: implemented
Update: the `reported` option this note introduced is gone with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md). The registry keeps no report bit; `dsh-tool-jobs` claims only its own `job_kill` and waits in a ledger, so a human `job.kill` leaves the notice due by construction. The reason merge, the `job.kill` Remote, and the two-press control are unchanged.
English | [中文](2026-08-26-human-job-kill.zh.md)
## Problem
The [web job display note](2026-08-08-web-background-job-display.md) shipped the task list read-only and recorded why: `JobRegistry.kill()` marks the job `reported`, and the [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.md) completion reporter suppresses the settlement notice for a reported job. That coupling is correct for the one caller that existed — the model's `job_kill`, whose own tool result already tells the model what it did — but a human pressing a stop button has no model-visible channel at all. A kill written against that contract would leave the model believing its task is still running, exactly the stale-world-model failure Claude Code ships today (its `/tasks` kill sets `notified: true` and the model learns nothing) and Kimi avoids (a model `TaskStop` suppresses its own notification; a human stop delivers one).
## Decision
A delivery claim means "the terminal state has a committed delivery path to the model", so the fix is to stop conflating cancellation with claiming that delivery.
- **A human kill claims no delivery.** `JobRegistry.kill(id, { reason? })` records the reason only; the delivery ledger lives in `dsh-tool-jobs`, which claims a job for the model's own `job_kill` and for its waits, so a `job.kill` from the task list leaves the settlement to the existing completion reporter (wakeup/inject, wake budget, truncation all unchanged). It never clears a claim the model already holds, and a terminal record stays exactly as settlement left it.
- **A recorded kill reason merges into a `killed` settlement's detail** — producer facts first (`signal: SIGTERM; cancelled by the user`) — so the completion notice and the web row both say who stopped the work without a new snapshot field or notice template. A job that outruns its kill (settles `completed`/`failed`) keeps the producer detail alone. This is the reconciliation Codex spells out in its `<turn_aborted>` guidance: when the user stops something, the model is told rather than left to infer.
- **`job.kill` is a Job Controller Remote** (`@deepseek-ai/dsh-api-job-controller`, beside `job.follow`; it began life as `session.killJob` on the Session Controller and moved with the observation stream when that package split out) with the same live-only Agent lookup (`ctx.agents.get`) as `session.cancel`: a running job's owner is alive by the registry's ownership contract, and killing from a list must not revive a Session any more than listing does. Unknown and foreign jobs collapse to one `job/not-found` rejection scoped to the lookup — a producer-cancel throw propagates per the registry contract instead of masquerading as a lookup failure; the controller requires `ctx.jobs` to load, so a composition without a registry has no kill Remote at all. The registry's owner fence is the only access rule: the Session Controller's subagent ownership fence is not applied, so a child session's own jobs are killable from its list (review rejected exporting that fence's helpers from the Session Controller package, and the rule adds nothing for a job the child owns), and the controller depends on neither the agent registry nor the Session Controller. No approval interaction: this single-user local BFF treats it as the same class of action as the turn-cancel button.
- **The task list's stop control is two-press** (arm, then confirm within 3s, matching the Kimi `s`+`y` and OpenCode double-Esc shape), rendered only on running job rows — rows with a `jobId`. Standalone activity rows (workflow runs) have no kill handle and no button. The pending press disables the control and stays pending on an admitted kill — the unary response and the jobs frames have no cross-carrier ordering, so only the authoritative frame (the row leaving the killable set) releases it; a rejected kill shows a brief hint. Because the kill addresses `ctx.jobs` by id, every job kind gains the control at once — bash, pwsh, pty, and one-shot background subagents.
## Alternatives considered
- **A `notify: boolean` option** — rejected because the registry cannot promise a notice (quiet delivery, unowned jobs, owner teardown all legitimately drop one); the ledger's claim names the fact the tool actually controls: whether one of its own tool results already delivered the terminal state.
- **A separate `interrupt()`/`stopByUser()` method** — one cancellation path with an explicit claim beats two methods whose only difference is a boolean, and Kimi's split (`stop` vs `stopByUser`) shipped with its TUI calling the wrong one.
- **Saying nothing to the model (Claude Code's shape)** — rejected; their own source comments question it, and the DSH blocker note recorded the stale-belief failure as the reason the control did not ship earlier.
## Testing
`tool-jobs` pins the ledger (a kill the model did not request leaves the notice due; its own `job_kill` and waits claim it) and the delivered notice text verbatim, reason included; `jobs-local` pins the detail merge on `killed` and the outran-kill case. `kill.host.spec.ts` pins the Remote command: admission, unowned-job kill from an agentless session, `job/not-found` for unknown/foreign. The client suites pin the RPC passthrough and the two-press control (arm, confirm, disarm timer, failure hint, single armed row, stale-phase cleanup); the keyless web e2e drives the button end to end.
## Consequences
- The completion notice for a human kill wakes an idle owner (default `wakeup` delivery) — a deliberate cost: an unclaimed completion the model never learns about is the failure this note exists to fix.
- `kill`'s positional `reason` parameter is gone (pre-release, no shim); the options object is the only form.
- A killed job's `detail` may now carry two clauses joined by `; `. Anything parsing `detail` as a single producer fact must treat it as opaque text, which `JobView.detail` always declared it to be.
@@ -0,0 +1,36 @@
# Agent Note:人工任务终止——用「未认领的终态报告」而非第二条取消路径
状态:已实现
Update:本文引入的 `reported` 选项已随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md) 消失。注册表不再保留播报位;`dsh-tool-jobs` 只在台账里认领自己的 `job_kill` 与等待,因此人类的 `job.kill` 天然让通知保持待送达。原因合并、`job.kill` Remote 与两击式控件保持不变。
[English](2026-08-26-human-job-kill.md) | 中文
## 问题
[Web 任务展示 Note](2026-08-08-web-background-job-display.zh.md) 把任务列表交付为只读并记下了原因:`JobRegistry.kill()` 会把任务标记为 `reported`,而 [`dsh-tool-jobs`](../../../../packages/jobs/tool-jobs/README.zh.md) 的完成播报员对已 reported 的任务抑制结算通知。这个耦合对当时唯一的调用方是正确的——模型的 `job_kill` 自己的 tool result 已经告诉模型它做了什么——但按下停止按钮的人没有任何模型可见渠道。照那份契约写一个人工 kill,模型会一直以为任务还在跑,正是 Claude Code 今天在售的过期世界模型失败(其 `/tasks` kill 置 `notified: true`,模型什么也学不到),也正是 Kimi 避开的(模型 `TaskStop` 抑制自己的通知;人工 stop 投递一条)。
## 决策
送达认领的本义是「终态已有一条向模型送达的承诺路径」,所以修法是不再把「取消」与「认领这次送达」混为一谈。
- **人类的 kill 不认领送达。** `JobRegistry.kill(id, { reason? })` 只记录原因;送达台账在 `dsh-tool-jobs` 里,它只为模型自己的 `job_kill` 与等待认领任务,因此来自任务列表的 `job.kill` 让结算照常走完成播报员(唤醒/注入、唤醒预算、截断全部不变)。它绝不清除模型已持有的认领,终态记录也保持结算时的原样。
- **记录下来的 kill reason 合入 `killed` 结算的 detail**——生产者事实在前(`signal: SIGTERM; cancelled by the user`)——完成通知与 Web 行因此都能说清是谁停的工作,无需新快照字段或新通知模板。跑赢 kill 的任务(结算为 `completed`/`failed`)只保留生产者 detail。这正是 Codex 在 `<turn_aborted>` 指引里写明的对账原则:用户停掉了什么,要告诉模型,而不是让它去猜。
- **`job.kill` 是 Job Controller 的 Remote**(`@deepseek-ai/dsh-api-job-controller`,与 `job.follow` 并列;它最初是 Session Controller 上的 `session.killJob`,该包拆出时随观测流一起迁走),与 `session.cancel` 一样只查活体 Agent(`ctx.agents.get`):按注册表的所有权契约,在跑任务的 owner 必然活着;从列表里 kill 与列表本身一样绝不复活 Session。未知与他人任务合并为一个只作用于查找的 `job/not-found` 拒绝——生产者 cancel 抛错按注册表契约原样传播,绝不伪装成查找失败;控制器加载就要求 `ctx.jobs`,没有注册表的组合根本没有 kill Remote。注册表的所有者围栏是唯一的访问规则:不施加 Session Controller 的 subagent 所有权栅栏,子会话自己的 job 可以从它的列表里被杀停(评审否决了从 Session Controller 包导出该栅栏的实现函数,而这条规则对子会话自有的 job 也没有额外意义),控制器既不依赖 agent 注册表也不依赖 Session Controller。不加 approval 交互:这个单用户本地 BFF 把它视为与停轮按钮同级的操作。
- **任务列表的停止控件为两击式**(先武装、3 秒内确认,对齐 Kimi 的 `s`+`y` 与 OpenCode 的双 Esc),只渲染在运行中的 job 行——即带 `jobId` 的行。独立 activity 行(workflow 运行)没有 kill 句柄也就没有按钮。请求进行中按钮禁用,且受理成功后保持 pending——unary 响应与 jobs 帧没有跨载体顺序保证,只有权威帧(行离开可杀集合)才释放控件;被拒绝的 kill 短暂提示。由于 kill 按 id 寻址 `ctx.jobs`,所有任务 kind 一次性获得该控件——bash、pwsh、pty 与一次性后台 subagent。
## 备选方案
- **`notify: boolean` 选项**——否决:注册表承诺不了通知(quiet 投递、无主任务、owner 拆除都会正当地丢弃一条);台账的认领命名的是工具真正控制的事实:它自己的某个 tool result 是否已经送达了终态。
- **独立的 `interrupt()`/`stopByUser()` 方法**——一条取消路径带显式认领胜过两个只差一个布尔的方法;Kimi 的拆分(`stop` 与 `stopByUser`)上线时 TUI 就调错了那个。
- **对模型保持沉默(Claude Code 的形状)**——否决;他们自己的源码注释都在质疑它,而 DSH 的阻塞 Note 正是把过期认知失败记为该控件此前不能上线的原因。
## 测试
`tool-jobs` 钉住台账(不是模型请求的 kill 让通知保持待送达;它自己的 `job_kill` 与等待认领它)与逐字的带原因投递通知文本;`jobs-local` 钉住 `killed` 的 detail 合并与跑赢 kill 的情形。`kill.host.spec.ts` 钉住 Remote 命令:受理、无 agent 会话 kill 无主任务、未知/他人的 `job/not-found`。客户端套件钉住 RPC 透传与两击控件(武装、确认、解除计时、失败提示、单一武装行、过期相位清理);无密钥 web e2e 端到端驱动按钮。
## 后果
- 人工 kill 的完成通知会唤醒空闲 owner(默认 `wakeup` 投递)——这是刻意的代价:模型永远学不到的未认领完成,正是本 Note 要修的失败。
- `kill` 的位置参数 `reason` 已删除(pre-release,无垫片);options 对象是唯一形式。
- 被 kill 任务的 `detail` 可能携带以 `; ` 连接的两个子句。任何把 `detail` 当单一生产者事实解析的代码都必须把它当不透明文本——`JobView.detail` 一直如此声明。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-26-shell-execute-projection-and-jobs-at-start.md
2026-08-26-shell-execute-projection-and-jobs-at-start.md: 2a03cd9f99645c10377dc9d2a5fdc68cb5ab0a4a
2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md: bdc6cc432dbb8e3a0a8d0fc86cf055f3938ff201
@@ -0,0 +1,52 @@
# Agent Note: One shell execute() — foreground as a projection, timeout as a bounded wait on the job
Status: implemented
Update: the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md) owns the output ring, the cursors, and the pull sources every registered command feeds.
English | [中文](2026-08-26-shell-execute-projection-and-jobs-at-start.zh.md)
## Problem
A bash command that outran its foreground timeout was killed, discarding the work — the single most common way a long build or install failed under the agent. Fixing that inside the old seam was structurally awkward: `ctx.shell` had two execution methods, `run()` (deadline fused in, promise-only, no handle to keep) and `start()` (handle, no deadline), so "keep this already-running foreground command" had no expression — the deadline owner could only kill, and the caller had nothing to re-register with `ctx.jobs`. The two methods had also drifted: different stdout budgets, a documented "start ignores timeoutMs" wart, and sync-vs-async spawn-failure behavior that differed per path.
Peer evidence pointed one way. Kimi keeps a timed-out command running by default (`bashAutoBackgroundOnTimeout`), rebinding nothing — the process task detaches, the caller signal unhooks, and the partial output returns with the handle. Claude Code's timeout handler calls `background()` on the same `ShellCommand` instead of killing, with an explicit comment that respawning would leak cleanups and duplicate events. Codex has no hand-over because it never had a "foreground spawn" at all: every `exec_command` is a bounded observation window over a session, and "still running" is a normal return carrying the handle. All three agree on the underlying shape: there is one way to execute, and foreground is a property of the wait, not the spawn.
The first shipped version of this change expressed the hand-over on the seam: an `onExpiry: 'offer'` policy, `ShellExecution.promotion`, and a `ShellPromotionOffer` the tool had to answer synchronously at the deadline. Review rejected it: the protocol existed only because the tool registered the job at the timeout instead of at the start, so every executor carried a relay/timer/detach arm and every consumer a timing obligation that no execution needs.
## Decision
**The seam converges to `resolve()` + `execute()`.** `execute(spec)` returns `Promise<ShellExecution>` — the live `ShellProcess` itself plus the foreground projection `result()` (split collected streams, first-cause `timedOut`/`aborted`, rejecting only for infrastructure failures). `run()` and `start()` are deleted (pre-release, no shims); the historical differences they encoded became explicit inputs: `ShellExecSpec.onExpiry` is `'kill'` (the default) or `'none'`, and one stdout budget (`spec.stdoutMaxBytes`) applies everywhere. There is no hand-over protocol: a caller that wants to wait only for a while runs the command under `'none'` and bounds its own wait, and the handle stays valid after the caller stops waiting.
**Preparation shares the execution deadline.** The handle is published after asynchronous confinement finishes. Preparation cancellation or failure rejects `execute`; expiry returns a settled timed-out handle with empty output. A late preparation result cannot spawn work. Provider facts are installed synchronously before subprocess settlement so an immediately failed launch still receives its sandbox classification.
**Spawn failures are contained uniformly**: a synchronous spawn throw and an asynchronous rejection both settle the handle as `killed` with the note on the read path, while `result()` rejects with the original error (identity preserved — the sandbox executors map runner-attributed failures to `SANDBOX_UNAVAILABLE` in their `result()` decoration and stamp handle facts in `onProcessDone`, both keyed on the one handle instance).
**With a job registry composed, `tool-bash`/`tool-pwsh` register every command with `ctx.jobs` at its start.** `run_in_background` returns the id at once. A foreground call starts the same job and waits on it with the registry's `wait(id, timeoutMs, owner, signal)`, the existing `timeoutMs` being the wait bound — no second knob. A command that settles within the wait returns the ordinary foreground result from the handle's `result()` and the tool removes the job record (`JobRegistry.remove`), so the model never sees an id and the list does not accumulate every `ls`. A command that outlives the wait keeps running as the job it already was, and the call returns `{ kind: 'promoted', jobId, timeoutMs, output }` — rendered as `[still running after <ms>; moved to background job <id>]` plus hand-off guidance — seeded with one consuming registry read, so `job_output` continues exactly after it. Nothing changes hands at the timeout: not the process, not the abort signal, not the output. The tool never passes its own signal to the process; cancelling the call kills the job, and a kill from outside the call (the human's stop control, a parallel `job_kill`) reaches the process through the job's `cancel` hook, whose reason the tool renders as `[stopped: <reason>]` in the foreground result (`stopped` on the value) so the model reads why instead of a command failure.
**The background surface follows the registry, and the registry stays optional.** The tool's `inject` does not name `jobs`. It registers a foreground-only definition while no registry is composed, replaces it with the job-backed definition inside a `ctx.inject(['jobs'], …)` fork for as long as `ctx.jobs` is present, and restores the foreground-only one when the registry unloads while the plugin stays. A composition without the registry therefore keeps a plain `bash` whose schema advertises neither `run_in_background` nor the hand-over, and the model sees no `job_*` tools. Registration is best-effort: `promoteOnTimeout: false`, or a registry that refuses the job at its start (the owner's admission limit, no attached controller), runs the command under the executor's `'kill'` deadline instead, logged.
**The registry reports what a waiting caller already collected.** The `settled` event carries `awaited`: whether the settlement released a live `wait`, which is true for the tool waiting on its own foreground command exactly as for a `job_output` wait. `dsh-tool-jobs` withholds the completion notice for awaited settlements and for kills the model requested through `job_kill`, and its private claim ledger is gone; a timed-out or aborted wait has already left the registry's waiter set, so a later settlement notifies as usual. A human kill from the web controller is neither, so the owning agent still receives the notice, reason included.
## Alternatives considered
- **A third method (`begin()`) beside `run`/`start`** — the first design. Rejected in review: the three "methods" were two orthogonal inputs (deadline policy; which projection the caller awaits) wearing method names, and the peer products all model one execution with projected views. Converging deleted the budget/wart drift instead of adding a surface.
- **Codex's session model** (every call an observation window, polling via `write_stdin`) — rejected as a model-facing vocabulary change duplicating what `ctx.jobs` already provides; its early-yield result contract survives in the promoted arm.
- **A separate hand-over threshold config** — rejected; "timeout means stop blocking the turn, not kill the work" needs no second timer, and an explicit model-passed `timeoutMs` bounds the wait the same way (both peers behave so; the schema text says it).
- **A promotion offer on the seam** (`onExpiry: 'offer'`, `ShellExecution.promotion`, `ShellPromotionOffer` with a synchronous `accept()`/`decline()` answer) — the version this PR first shipped. Rejected in review because the deferred registration was the only reason for the protocol: the executor's offer arm relayed the caller's signal, armed its own timer, and detached the signal on accept, all of which a consumer holding the handle can do itself, and none of which is needed once the job exists from the start.
- **A tool-owned deadline over `onExpiry: 'none'` without registering at start** — removes the seam protocol but keeps the command invisible and unkillable until the timeout; rejected because a listed, stoppable command from its first second is the point of the human kill.
- **A hard `jobs` dependency for the shell tools** — rejected: a composition that wants a shell without three model-visible `job_*` tools must stay possible, so the tool swaps definitions on the registry's presence instead.
- **Exempting waited-on commands from the admission cap, and an `attached` marker in `JobView`** — deferred: a refusal at the start falls back to the deadline kill, and grouping running foreground commands in the Web task list is a follow-up.
## Testing
Executor level (real processes): the `'kill'` and `'none'` arms classify first-cause; a caller abort before the deadline stays the first cause when the process outlives the deadline; a pre-aborted signal classifies `aborted`; preparation expiry yields a settled timed-out handle without spawning (sandbox suites); sync-throw containment with error identity through `result()`. Tool level: a real `printf …; sleep 30` with `timeoutMs: 250` is listed as `bash-1` while the call waits, returns the still-running text with the early output, and its next `job_output` read repeats nothing; a command that finishes within the wait returns the foreground result with the registry announcing `registered`, `settled` (`awaited: true`), `removed` and an empty list afterwards; a registry kill during the wait renders `[stopped: cancelled by the user]` before the signal marker; cancelling the call kills its job with `tool call aborted` as the reason; a start failure is the tool's error result with no job left; admission saturation falls back to the deadline kill with the warn; `promoteOnTimeout: false` keeps the kill, emits no job events, and drops the description sentence; the schema flips to job-backed when a registry loads later and back to foreground-only when it unloads, while disposing the tool plugin under a registry removes the tool without logging; pwsh mirrors every case over a scripted executor. Registry level: `awaited` for a live wait, `false` for none, timed-out, or aborted waits, and `true` while another wait on the job timed out; `remove` drops a settled record with a `removed` event and refuses a live job, an unknown id, and a foreign caller. Render texts are pinned verbatim.
## Consequences
- Every `ShellExecutor` consumer moved: the tools, the hook runner, tmux-context, and the webworker sandbox stack now speak `execute()`; executor test suites keep scenario intent through local `run`/`start` shims over the new seam.
- The `bash`/`pwsh` tool descriptions and `timeoutMs` schema text depend on the registry's presence (model-visible); the output union carries the `promoted` arm, and the foreground arm gained the optional `stopped` reason — the PTC system-prompt snapshots re-recorded with it.
- A registered command has no deadline at all: for a foreground call the timeout bounds only the wait, and stopping the command afterwards is `job_kill` or the web stop control.
- The Web task list shows every foreground command while it runs and streams it through `job.list`/`job.follow`; a settled one leaves with its result.
- `JobRegistry` gained `remove` and the `settled` event's `awaited` flag; `dsh-tool-jobs` keeps only the set of jobs the model killed.
- `start()`-era behavior where a synchronous spawn throw escaped to the caller is gone: callers read failures from `result()`/the read path. The sandbox "sync EACCES names the runner" classification now arrives as the `result()` rejection or the handle's `runnerFailed` fact.
@@ -0,0 +1,52 @@
# Agent Note:唯一的 shell execute()——前台是投影,超时是对 job 的有界等待
状态:已实现
Update:[jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md)拥有每条已登记命令现在填充的输出环、游标与拉取源。
[English](2026-08-26-shell-execute-projection-and-jobs-at-start.md) | 中文
## 问题
跑过前台超时的 bash 命令会被杀掉、工作作废——这是长构建或安装在 agent 手里失败的头号方式。在旧 seam 里修它结构上很别扭:`ctx.shell` 有两个执行方法,`run()`(deadline 内置、只给 promise、没有可保留的句柄)与 `start()`(有句柄、无 deadline),于是「保住这个已在运行的前台命令」无从表达——deadline 的所有者只会杀,调用方也拿不到可向 `ctx.jobs` 重新注册的东西。两个方法还各自漂移:stdout 预算不同、文档里挂着「start 忽略 timeoutMs」的疣、同步与异步 spawn 失败的行为按路径分叉。
同行证据指向同一个方向。Kimi 默认让超时的命令继续运行(`bashAutoBackgroundOnTimeout`),什么都不重绑——进程任务脱离、调用方信号解钩、部分输出随句柄返回。Claude Code 的超时处理对同一个 `ShellCommand` 调用 `background()` 而不是杀掉,并明确注释重新 spawn 会泄漏清理并重复事件。Codex 根本没有移交,因为它从未有过「前台 spawn」:每次 `exec_command` 都是对一个 session 的有界观察窗,「仍在运行」是携带句柄的正常返回。三家在底层形状上一致:执行只有一种方式,前台是等待的属性,不是 spawn 的属性。
本变更最初交付的版本把移交放在了 seam 上:`onExpiry: 'offer'` 策略、`ShellExecution.promotion`,以及工具必须在 deadline 处同步应答的 `ShellPromotionOffer`。评审否决了它:这套协议存在的唯一原因是工具把 job 登记推迟到了超时点而不是启动点,于是每个执行器都要背一条中继/计时/解绑分支,每个消费方都要背一条任何执行都不需要的时序义务。
## 决策
**seam 收敛为 `resolve()` + `execute()`。** `execute(spec)` 返回 `Promise<ShellExecution>`——活的 `ShellProcess` 本体加前台投影 `result()`(分流收集、首因 `timedOut`/`aborted` 归类、只为基础设施失败 reject)。`run()` 与 `start()` 删除(pre-release,无垫片);它们编码的历史差异变成显式输入:`ShellExecSpec.onExpiry` 取 `'kill'`(默认)或 `'none'`,stdout 预算统一为 `spec.stdoutMaxBytes`。没有任何移交协议:只想等一段时间的调用方以 `'none'` 运行命令并自己限定等待时长,调用方停止等待后句柄依然有效。
**准备与执行共用 deadline。** 异步沙箱准备完成后才发布句柄。准备期间的取消或失败会拒绝 `execute`;到期则返回已结算的超时句柄,输出为空。迟到的准备结果不会启动任务。提供方事实在 subprocess 结算前同步安装,立即失败的启动也能获得对应的沙箱归类。
**spawn 失败统一收容**:同步抛错与异步 rejection 都把句柄结算为 `killed`、说明进入读路径,而 `result()` 以原始错误 reject(保留同一性——沙箱执行器在其 `result()` 装饰里把可归因于 runner 的失败映射为 `SANDBOX_UNAVAILABLE`,并在 `onProcessDone` 里盖章句柄事实,两者都以同一个句柄实例为键)。
**组合中有 job 注册表时,`tool-bash`/`tool-pwsh` 在每条命令启动时就把它登记到 `ctx.jobs`。** `run_in_background` 立即返回 id。前台调用启动同样的 job,并用注册表的 `wait(id, timeoutMs, owner, signal)` 等待它,等待上限就是现有的 `timeoutMs`——没有第二个旋钮。在等待内结算的命令用句柄的 `result()` 返回普通前台结果,工具随即移除该 job 记录(`JobRegistry.remove`),模型从不看到 id,列表也不会堆满每一条 `ls`。超过等待仍在运行的命令继续作为它本来就是的那个 job 运行,调用返回 `{ kind: 'promoted', jobId, timeoutMs, output }`——渲染为 `[still running after <ms>; moved to background job <id>]` 加交接指引——并以一次消费式注册表读取打底,因此 `job_output` 恰好从此处接续。超时那一刻没有任何东西易手:进程没有、中止信号没有、输出也没有。工具从不把自己的信号交给进程;取消调用会杀掉 job,而来自调用之外的杀停(人的停止控件、并行的 `job_kill`)经 job 的 `cancel` 钩子到达进程,工具把其原因渲染为前台结果里的 `[stopped: <reason>]`(值上的 `stopped`),模型读到的是原因而不是命令失败。
**后台能力面跟随注册表,注册表保持可选。** 工具的 `inject` 不写 `jobs`。没有注册表时它注册纯前台定义;`ctx.jobs` 出现后,`ctx.inject(['jobs'], …)` fork 用带 job 的定义替换它,注册表存在多久就保持多久;注册表在插件仍在时卸载,则恢复纯前台定义。因此没有注册表的组合保留一个 schema 既不宣传 `run_in_background` 也不宣传移交的普通 `bash`,模型也看不到任何 `job_*` 工具。登记是尽力而为的:`promoteOnTimeout: false`,或注册表在启动时拒绝该 job(持有者的准入上限、没有已附加的控制器),都改为在执行器的 `'kill'` deadline 下运行命令,并记录日志。
**注册表报告等待方已经收走了什么。** `settled` 事件带 `awaited`:本次结算是否释放了一个存活的 `wait`,工具等待自己的前台命令时为真,`job_output` 的等待亦然。`dsh-tool-jobs` 对已 awaited 的结算和模型经 `job_kill` 请求的杀停不发完成通知,其私有的认领台账已删除;超时或中止的等待早已离开注册表的等待方集合,之后的结算照常通知。来自 web 控制器的人类杀停两者都不是,因此拥有者 agent 照常收到带原因的通知。
## 备选方案
- **在 `run`/`start` 旁加第三个方法(`begin()`)**——最初的设计。评审中否决:三个「方法」其实是披着方法名的两根正交输入(deadline 策略;调用方等待哪个投影),同行产品全都建模为一次执行加投影视图。收敛顺带删掉了预算/疣的漂移,而不是再添一层表面。
- **Codex 的 session 模型**(每次调用都是观察窗、经 `write_stdin` 轮询)——否决:那是重复 `ctx.jobs` 已有能力的模型侧词汇变更;其早退结果契约保留在 promoted arm 里。
- **独立的移交阈值配置**——否决;「超时意味着别再阻塞回合,而不是杀掉工作」不需要第二个计时器,模型显式传的 `timeoutMs` 同样限定等待(两家同行皆如此;schema 文案已说明)。
- **seam 上的转移 offer**(`onExpiry: 'offer'`、`ShellExecution.promotion`、需同步 `accept()`/`decline()` 应答的 `ShellPromotionOffer`)——本 PR 最初交付的版本。评审否决,因为推迟登记是这套协议存在的唯一原因:执行器的 offer 分支中继调用方信号、自设计时器、accept 时解绑信号,这些持有句柄的消费方都能自己做,而 job 一开始就存在后一样都不需要。
- **不在启动时登记、只由工具在 `onExpiry: 'none'` 之上自设 deadline**——去掉了 seam 协议,但命令在超时前依然不可见、不可杀;否决,因为从第一秒起就被列出、可停止的命令正是人类杀停的意义所在。
- **shell 工具硬依赖 `jobs`**——否决:想要 shell 却不想让模型看到三个 `job_*` 工具的组合必须仍然可能,所以工具改为随注册表的存在切换定义。
- **让被等待的命令不占准入名额,以及 `JobView` 上的 `attached` 标记**——延期:启动时被拒回落到 deadline 杀,Web 任务列表对运行中前台命令的分组留作后续。
## 测试
执行器层(真实进程):`'kill'` 与 `'none'` 两个分支按首因归类;deadline 前的调用方 abort 在进程活过 deadline 时仍是首因;预先 abort 的信号归类 `aborted`;准备到期返回已结算的超时句柄且不 spawn(沙箱套件);同步抛错收容且错误同一性穿过 `result()`。工具层:真实的 `printf …; sleep 30` 配 `timeoutMs: 250` 在调用等待期间就以 `bash-1` 列出,返回带早期输出的仍在运行文本,其下一次 `job_output` 读取不重复任何内容;在等待内完成的命令返回前台结果,注册表依次播报 `registered`、`settled`(`awaited: true`)、`removed`,之后列表为空;等待期间的注册表杀停在信号标记前渲染 `[stopped: cancelled by the user]`;取消调用以 `tool call aborted` 为原因杀掉其 job;启动失败是工具的错误结果且不留 job;准入饱和回落到 deadline 杀并告警;`promoteOnTimeout: false` 保持杀、不发任何 job 事件、撤掉描述句;注册表稍后加载时 schema 切到带 job 的版本、卸载时切回纯前台,而在注册表存在时释放工具插件会移除工具且不记录任何错误;pwsh 以脚本化执行器镜像每个用例。注册表层:存活等待为 `awaited`,无人等待、超时或中止的等待为 `false`,另一个等待超时时仍为 `true`;`remove` 以 `removed` 事件丢弃已结算记录,并拒绝存活 job、未知 id 与外部调用方。渲染文本逐字钉死。
## 后果
- 每个 `ShellExecutor` 消费方都已迁移:工具、hook runner、tmux-context 与 webworker 沙箱栈现在都说 `execute()`;执行器测试套件用本地 `run`/`start` 垫片保住场景语义。
- `bash`/`pwsh` 的工具描述与 `timeoutMs` schema 文案取决于注册表是否存在(模型可见);输出 union 保留 `promoted` arm,前台 arm 新增可选的 `stopped` 原因——PTC 系统提示词快照随之重录。
- 已登记的命令完全没有 deadline:前台调用的超时只限定等待,之后停下命令靠 `job_kill` 或 Web 停止控件。
- Web 任务列表显示每条运行中的前台命令并经 `job.list`/`job.follow` 流式观看;完成的命令随结果离开。
- `JobRegistry` 新增 `remove` 与 `settled` 事件的 `awaited` 标志;`dsh-tool-jobs` 只保留模型杀停过的 job 集合。
- `start()` 时代同步 spawn 抛错逃逸给调用方的行为不复存在:调用方从 `result()`/读路径读失败。沙箱「同步 EACCES 指名 runner」的分类现在以 `result()` rejection 或句柄的 `runnerFailed` 事实呈现。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-01-workflow-run-in-background.md
2026-09-01-workflow-run-in-background.md: 5a141b384186e7b4f9e8125c37eaf2c01a614a6d
2026-09-01-workflow-run-in-background.zh.md: 102a398d95eef6df07443a919c19dae57b5c1e74
@@ -0,0 +1,31 @@
# Agent Note: Workflow runs gain `run_in_background` over the job registry
Status: implemented
Update: with the [jobs seam consolidation](../architecture/2026-09-03-jobs-seam-consolidation.md) the run registers a `JobSpec` (no `record` flag), narrates through `JobHandle.append` and `updateProgress`, and returns its rendered value as `JobOutcome.result`, which the model's first read after settlement carries once.
English | [中文](2026-09-01-workflow-run-in-background.zh.md)
## Problem
A `workflow` call blocked the parent turn until the whole script settled: a long orchestration (an audit fanning out over hundreds of files) held the model hostage for its entire wall-clock, with no way to keep working, no live progress for a human, and cancellation as the only exit. Every other long-running execution surface — bash, pwsh, one-shot subagents — already had a `run_in_background` route into `ctx.jobs`. The [record merge](../architecture/2026-09-01-jobs-absorb-activity-record.md) also removed the foreground workflow's activity mirror on the explicit promise that live workflow narration would return as a background record job.
## Decision
The `workflow` tool gains `run_in_background: true` (exposed and accepted while the `enableRunInBackground` config holds, default on): the call registers the run as an owned `kind: 'workflow'` job with an observation record and returns `{ kind: 'background', jobId, runId }` immediately.
- **The run belongs to the job, not the tool step.** The engine run starts inside the job starter with no `exec.signal`; `job_kill`, the job list's stop control, and owner teardown are the cancellation paths, each forwarding its reason into `run.cancel`. A synchronous engine rejection (meta/parse failure) propagates out of the starter, so nothing registers and the model sees the ordinary correctable error.
- **Settlement is the job's settlement.** `done` chains from `run.result`: dispose (a disposal failure warns and never rejects into the registry), stop the mirrors, then map the stop reason — `completed` carries the same rendered return value as the foreground path in `JobOutcome.output` (so the completion notice and `job_output` deliver it), `cancelled` settles `killed` and leaves the detail to the registry's kill-reason merge (the forwarded cancel reason is the same string), `error` settles `failed` with the script's failure message.
- **The record mirror replaces the deleted activity mirror.** `src/record.ts` subscribes `workflow/phase`, `workflow/log`, and member lifecycle events once per plugin and routes them into tracked runs' `JobHandle` faces as the same text lines the activity mirror wrote, on the `log` channel so the model's `job_output` never renders them; `updateProgress` tracks the current phase. There is no contained-error wrapper: `append`/`updateProgress` are non-throwing surfaces (post-settlement appends drop inside the registry), and a straggling event finds no tracked run.
- **The output schema becomes a `kind`-discriminated union** (`background` | `foreground`), matching bash's shape; the foreground envelope gains `kind: 'foreground'` for symmetry. The durable run-start/member/run-end session recording keeps its `exec.parent === undefined` gate on both paths, with the background run-end written from the job's `done` chain.
- **`workflow` joins `JobKindMap`** by declaration merging from the tool package, like `pwsh` and `pty`.
## Alternatives considered
- **A start/poll API on the tool itself** (a `workflow_status` companion): duplicates `job_output`/`job_kill` for one producer and leaves the run outside owner teardown and the session-header list; the job registry already is the start/poll surface.
- **Streaming intermediate values to the model** (partial results through `readOutput`): a workflow's value is the script's single return; per-agent intermediates are script-internal (`log()` narrates them for humans through the record). A consuming model cursor would invite polling loops against an inherently final-value producer.
- **Bridging `exec.signal` into the background run:** the returning tool step's abort (turn cancellation) would kill work the model just intentionally detached; bash's background route set the precedent that only the registry cancels.
## Consequences
The model can fire long orchestrations and keep working; humans watch phases, logs, and member lifecycles stream in the session-header job list and can stop the run there. A background run's value arrives only at settlement (`job_output` before that returns status alone). The snapshot tree pins the schema, prompt, and PTC stub changes; a recorded-session scenario replaying a full background run is deferred and tracked in the package README's limitations.
@@ -0,0 +1,31 @@
# Agent Note:workflow 运行经任务注册表获得 `run_in_background`
状态:已实现
Update:随 [jobs seam 收敛](../architecture/2026-09-03-jobs-seam-consolidation.zh.md),该运行注册的是 `JobSpec`(没有 `record` 标志),通过 `JobHandle.append` 与 `updateProgress` 叙述进度,并把渲染后的值作为 `JobOutcome.result` 返回,模型在结算后的第一次读取携带它一次。
[English](2026-09-01-workflow-run-in-background.md) | 中文
## 问题
`workflow` 调用会阻塞父级轮次直到整个脚本结算:一次长编排(扇出到数百个文件的审计)把模型按在原地耗完全部墙钟时间,模型无法继续工作,人类看不到实时进度,取消是唯一出口。其他每个长时执行面——bash、pwsh、一次性 subagent——都早已有通往 `ctx.jobs` 的 `run_in_background` 路径。[record 合并](../architecture/2026-09-01-jobs-absorb-activity-record.zh.md)删掉前台 workflow 的 activity 镜像时,也明确承诺实时 workflow 叙述会以后台 record 任务的形式回归。
## 决策
`workflow` 工具获得 `run_in_background: true`(在 `enableRunInBackground` 配置生效时公开并受理,默认开启):调用把运行注册为自有的 `kind: 'workflow'` 任务并声明观察 record,立刻返回 `{ kind: 'background', jobId, runId }`。
- **运行属于任务,不属于工具步骤。** 引擎运行在任务 starter 内启动且不带 `exec.signal`;取消路径是 `job_kill`、任务列表的停止控件与 owner 释放,各自把原因转发进 `run.cancel`。引擎的同步拒绝(meta/解析失败)从 starter 传播出去,因此什么都不注册,模型看到的是普通的可修正错误。
- **结算即任务结算。** `done` 从 `run.result` 链起:先 dispose(释放失败只告警,绝不 reject 进注册表),再停掉镜像,最后映射结束原因——`completed` 在 `JobOutcome.output` 里携带与前台路径相同的渲染返回值(完成播报与 `job_output` 由此送达它),`cancelled` 结算为 `killed` 并把 detail 留给注册表的 kill 原因合并(转发的取消原因就是同一字符串),`error` 以脚本失败消息结算为 `failed`。
- **record 镜像顶替被删的 activity 镜像。** `src/record.ts` 按插件订阅一次 `workflow/phase`、`workflow/log` 与成员生命周期事件,并把它们以 activity 镜像当年写下的同样文本行以 `log` 通道路由进被跟踪运行的 `JobHandle` 面(模型的 `job_output` 从不渲染它们);`updateProgress` 跟随当前 phase。这里没有收容错误的包装层:`append`/`updateProgress` 是不抛出的表面(结算后的 append 在注册表内丢弃),迟到的事件找不到被跟踪的运行。
- **输出 schema 变为 `kind` 判别联合**(`background` | `foreground`),与 bash 的形态一致;前台包络为对称获得 `kind: 'foreground'`。持久的 run-start/成员/run-end 会话记录在两条路径上都保留 `exec.parent === undefined` 门槛,后台的 run-end 从任务的 `done` 链写出。
- **`workflow` 进入 `JobKindMap`**,由工具包声明合并,与 `pwsh`、`pty` 相同。
## 曾考虑的替代方案
- **工具自带 start/poll 接口**(一个 `workflow_status` 伴生工具):为单一生产者复制 `job_output`/`job_kill`,还把运行留在 owner 释放与会话头部列表之外;任务注册表本身就是 start/poll 表面。
- **向模型流式中间值**(经 `readOutput` 给部分结果):workflow 的值就是脚本的单一 return;每个 agent 的中间结果是脚本内部事务(`log()` 经 record 向人类叙述它们)。给天然只有终值的生产者一个消费型模型游标,只会招来轮询循环。
- **把 `exec.signal` 桥接进后台运行:** 已返回的工具步骤的中止(轮次取消)会杀掉模型刚刚有意脱管的工作;bash 的后台路径已立下先例——只有注册表取消它。
## 后果
模型可以点燃长编排后继续工作;人类在会话头部任务列表里看着 phase、log 与成员生命周期流入,并能就地停止运行。后台运行的值只在结算时抵达(此前的 `job_output` 只返回状态)。快照树钉住 schema、提示词与 PTC 桩的变化;回放完整后台运行的 recorded-session 场景延期,记录在包 README 的限制一节。
@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
2026-09-08-desktop-uninstall-preserve-dsh-home.md: 921d9681aa6a26c39e8861d2156dc868237f5b37
2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: cb9187bb6d7c3f619dc083fcc3b36cca09494515
@@ -0,0 +1,39 @@
# Agent Note: Windows uninstall retains the Harness home
Status: implemented
English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)
## Problem
Desktop and CLI share conversations, credentials, settings and plugins in the Harness home. Electron keeps browser storage and caches under `%APPDATA%`, and the updater keeps downloaded installers under `%LOCALAPPDATA%`; the upstream NSIS template leaves both behind unless a command-line flag is passed. Users who uninstall expect the application to leave no residue, while users who reinstall expect their conversations back.
## Decision
Windows uninstallation removes the Electron user-data directory, the `%APPDATA%` product directory and the updater cache together with the application, and never touches the Harness home. This is the 2026-09-08 proposal in amended form: Windows only, without the installed-artifact inventory programme, and without a user choice. No custom uninstaller page exists; silent uninstallation removes the same data. An uninstaller started with `--updated` or `/KEEP_APP_DATA` retains it, which covers electron-builder's in-place update and its replacement of an older installation from another directory, so updates keep UI preferences. A `DSH_HOME` published as a Windows environment variable protects every target overlapping it; the native helper cannot see a home configured only in a shell profile.
Electron derives its user-data directory from the scoped package name, so the removal targets `%APPDATA%\@deepseek-ai\dsh-desktop` and afterwards removes ordinary empty parents below `%APPDATA%`, never a linked or populated scope directory. Removal runs through the native helper in `window-frame.dll`, which rejects protected Windows folders and paths overlapping the installation or the protected home, holds directory handles with listing access during traversal so the directory cannot be renamed or replaced underneath it, unlinks reparse points without entering their targets, clears the read-only attribute, and keeps deleting siblings after a locked file. It requires a fixed local drive and leaves a linked root or ancestor in place. A failure leaves residue and does not stop or report on uninstallation; the uninstaller has no custom copy.
Installation also records `InstallLocation` on the Windows uninstall entry as standard inventory metadata. Launching the uninstaller directly from the Start menu context menu is unavailable to Win32 installers on Windows 11; only MSIX packages uninstall from there, so that request is outside this decision.
## Alternatives considered
**Opt-in removal on a custom uninstaller page, including the desktop profile and the whole home.** The desktop profile, sessions and credentials belong to the home shared with the CLI and stay in place for reinstallation; the remaining Electron data holds only UI preferences and caches, which does not justify a choice the user must understand, a recorded home path for the uninstaller, and a bilingual native dialog.
**Delete the whole home when desktop is the only profile.** Profile presence does not establish whether shared data is needed by CLI or a future reinstall.
**Leave Electron data in place, matching the upstream default.** Chromium caches grow to hundreds of megabytes and are the residue users report after uninstalling.
**Use electron-builder's `deleteAppDataOnUninstall`.** The upstream `RMDir /r` follows no ownership rules, ignores long paths and enters linked directories; the native helper owns removal instead.
**Establish the cleanup inventory by observing disposable accounts across install, update and uninstall.** The three roots follow from Electron's `userData` derivation and electron-builder's updater cache name, both fixed by configuration; observation would confirm what the configuration already states.
## Deferred
macOS cleanup (a signed helper behind an **Uninstall and keep Harness data** action, since drag-to-Trash cannot run application code) remains open under #3540 and is not decided here.
## Consequences
Reinstallation restores conversations, settings and plugins but not UI layout preferences. The desktop profile under the home remains with its packages and lock files. Other historical homes and user projects are not discovered or deleted. Roaming profiles on network or removable drives are left in place.
Qualification uses isolated product identities with a per-run scoped package name. Source regressions cover template adaptation; the native remover regression covers refused roots, the protected home, rename blocking while pinned, read-only and locked files, and empty-parent removal; the uninstaller fixtures cover interactive, silent, `--updated`, `/KEEP_APP_DATA`, and a `DSH_HOME` inside the Electron directory, with a junction to an external project inside the removed data. The standard uninstaller pages render correctly at 200% scaling on the development host.
@@ -0,0 +1,39 @@
# Agent Note: Windows 卸载保留 Harness 主目录
Status: implemented
[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文
## 问题
Desktop 和 CLI 在 Harness 主目录中共享会话、凭据、设置和插件。Electron 把浏览器存储和缓存放在 `%APPDATA%` 下,更新器把下载的安装包放在 `%LOCALAPPDATA%` 下;上游 NSIS 模板在没有命令行参数时会把两者都留下。卸载的用户期望应用不留残余,重装的用户期望会话仍在。
## 决策
Windows 卸载随应用一起删除 Electron 用户数据目录、`%APPDATA%` 下的产品目录和更新器缓存,从不触碰 Harness 主目录。这是 2026-09-08 提案的修正形式:仅限 Windows,不含已安装产物清单方案,也不让用户选择。卸载程序没有自定义页面;静默卸载删除相同的数据。以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,这覆盖了 electron-builder 的原地更新和从其他目录替换旧安装两种情况,因此更新保留界面偏好。以 Windows 环境变量发布的 `DSH_HOME` 会保护所有与其重叠的目标;原生辅助程序看不到只在 shell 配置文件中设置的主目录。
Electron 根据带作用域的包名派生用户数据目录,因此删除目标是 `%APPDATA%\@deepseek-ai\dsh-desktop`,随后移除 `%APPDATA%` 之下普通且已空的父目录,从不移除链接或非空的作用域目录。删除通过 `window-frame.dll` 中的原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或受保护主目录重叠的路径,遍历期间以列举权限持有目录句柄,使目录无法在其下被改名或替换,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项。它要求固定的本地驱动器,链接的根目录或祖先目录会原样保留。失败只留下残余,不中止也不提示卸载;卸载程序没有任何自定义文案。
安装时还会在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据。Windows 11 上 Win32 安装包无法让开始菜单右键菜单中的“卸载”直接启动卸载程序,只有 MSIX 包能做到,因此该需求不在本决策范围内。
## 备选方案
**在自定义卸载页上按需删除,包括桌面配置档案和整个主目录。** 桌面配置档案、会话和凭据属于与 CLI 共享的主目录,为重装而保留;剩余的 Electron 数据只包含界面偏好和缓存,不值得让用户理解并做出选择,也不值得为卸载程序记录主目录路径并维护双语原生对话框。
**当桌面是唯一配置档案时删除整个主目录。** 配置档案的存在不能证明共享数据是否被 CLI 或将来的重装需要。
**沿用上游默认,保留 Electron 数据。** Chromium 缓存会增长到数百 MB,正是用户卸载后反馈的残余。
**使用 electron-builder 的 `deleteAppDataOnUninstall`。** 上游的 `RMDir /r` 不遵守归属规则、不支持长路径并会进入链接目录;改由原生辅助程序负责删除。
**通过在一次性账户上观察安装、更新和卸载来建立清理清单。** 三个根目录由 Electron 的 `userData` 派生规则和 electron-builder 的更新器缓存名决定,两者都由配置固定;观察只会确认配置已经说明的事实。
## 推迟事项
macOS 清理(由签名辅助程序支撑的“**卸载并保留 Harness 数据**”操作,因为拖到废纸篓无法运行应用代码)仍在 #3540 下开放,本决策不涉及。
## 结果
重装恢复会话、设置和插件,但不恢复界面布局偏好。主目录下的桌面配置档案及其包和锁文件保留。其他历史主目录和用户项目不会被发现或删除。位于网络或可移动驱动器上的漫游配置文件原样保留。
验收使用每次运行唯一的带作用域包名的隔离产品身份。源码回归覆盖模板适配;原生删除器回归覆盖被拒绝的根目录、受保护的主目录、持有期间阻止改名、只读与被占用文件,以及空父目录移除;卸载夹具覆盖交互、静默、`--updated`、`/KEEP_APP_DATA`,以及位于 Electron 目录内的 `DSH_HOME`,被删除的数据内含指向外部项目的 junction。标准卸载页面在开发机 200% 缩放下渲染正确。
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-16-browser-excel-preview.md
2026-09-16-browser-excel-preview.md: 5631906931122e45d4381a570c095203655b6435
2026-09-16-browser-excel-preview.zh.md: 4107b986246415bc640b683650d2b18f03a786c8
2026-09-16-browser-excel-preview.md: 208bcdcd69d8ac30fcb5a7776271942424a92a05
2026-09-16-browser-excel-preview.zh.md: c48d9156e44befaa265d5c1f4116d86efe099fda
@@ -16,7 +16,7 @@ Preview is read-only and retains formula text alongside saved results. It does n
A disposable browser Worker contains parser CPU work. A source-byte limit precedes parsing, a combined rectangular-area limit precedes FortuneSheet matrix allocation, and a timeout terminates stalled parsing. These limits do not claim a hard memory sandbox. Styles are scoped to the renderer; external hyperlinks are text only.
The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@[email protected]) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under React 19. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry.
The pinned FortuneSheet React dependency carries a [patch](../../../../patches/@[email protected]) that skips the input box's read-only layout state update unless formula-reference highlighting is enabled. The unconditional update otherwise causes a maximum-update-depth failure when switching worksheets under the shipped React 18.3.1 runtime. Sheet activation also retains the adapter's complete initial selection instead of clearing it and leaving an empty or invalid address. The browser scenario covers these failures through the bundled ESM entry.
The read-only formula bar takes formulas from raw `cell.f` and writes formulas and cell text through `textContent`. FortuneSheet's formula formatter emits HTML spans containing document text, and its HTML helper passes formulas and strings starting with `<span` through unchanged. Neither read-only mode nor the parser Worker isolates this DOM. The [core patch](../../../../patches/@[email protected]) HTML-escapes copied cell contents, including literal strings and saved formula results, while preserving the clipboard table and formatting.
@@ -16,7 +16,7 @@ PDF 转换会丢失工作表导航以及公式与已保存值之间的关系。
独立可释放的浏览器 Worker 承担解析 CPU 工作。解析前检查源文件字节限制,FortuneSheet 分配矩阵前检查矩形区域合计限制,并通过超时终止停滞的解析。这些限制不代表严格的内存沙箱。样式限定在渲染器区域内,外部超链接仅显示为文本。
固定版本的 FortuneSheet React 依赖带有一个[补丁](../../../../patches/@[email protected]):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在 React 19 下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障。
固定版本的 FortuneSheet React 依赖带有一个[补丁](../../../../patches/@[email protected]):只读输入框仅在启用公式引用高亮时才提交布局状态更新。无条件更新会在仓库交付的 React 18.3.1 运行时下切换工作表时触发最大更新深度错误。激活工作表时还会保留适配层提供的完整初始选区,避免清空后留下空白或无效地址。浏览器场景通过打包后的 ESM 入口覆盖这些故障。
只读公式栏直接读取原始 `cell.f`,通过 `textContent` 写入公式和单元格文本。FortuneSheet 的公式格式化器会生成含有文档文本的 HTML span,其 HTML 辅助函数会原样放行公式和以 `<span` 开头的字符串。只读模式和解析 Worker 都不能隔离这部分 DOM。[core 补丁](../../../../patches/@[email protected])会对复制的单元格内容进行 HTML 转义,包括字面字符串和已保存的公式结果,同时保留剪贴板表格及其格式。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-08-desktop-uninstall-preserve-dsh-home.md
2026-09-08-desktop-uninstall-preserve-dsh-home.md: e1c2435814a4563f1fed6652d521928200d4ade7
2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md: 27fd7c8cbec360db35cadc75ff26b575629ad571
@@ -1,95 +0,0 @@
# Agent Note: Uninstall Desktop while preserving the Harness home
Status: proposed
English | [中文](2026-09-08-desktop-uninstall-preserve-dsh-home.zh.md)
## Problem
Users need Desktop uninstallation to remove its application files and external application state while retaining the complete Harness home. Removing an application directory does not account for browser storage, cached installers, or native updater state. Browser storage also contains unsent drafts and UI preferences, so removing it changes more than disk cache usage.
The [packaging configuration](../../../../apps/desktop/electron-builder.config.mjs), [desktop entry](../../../../apps/desktop/src/main.ts), and [update coordinator](../../../../apps/desktop/src/update-coordinator.ts) are the inspection inputs. Source inspection identifies cleanup candidates; installed-artifact observation must establish the complete supported inventory before implementation is accepted. No uninstall experiment has yet established an exhaustive Windows or macOS inventory.
## Proposal
Provide an uninstall operation whose successful result leaves the complete default `~/.dsh` and any configured `DSH_HOME` intact, removes Desktop-owned files outside those homes, and removes the selected application installation. Preserve plugin files, package-manager state, sessions, settings, and credentials within the retained homes. Do not launch or repair the Desktop profile during cleanup.
Windows Control Panel and Settings uninstallation will run this operation through NSIS. macOS will expose a localized **Uninstall and keep Harness data** action backed by a signed cleanup helper. Finder drag-to-Trash alone cannot invoke this operation; document that limitation and provide the same helper as a signed standalone uninstaller for users who already removed the application. A plain drag operation must not be advertised as complete cleanup.
User-created projects, separately installed CLI software, user-saved downloads and exports, arbitrary third-party plugin/tool outputs, and OS-maintained audit/security records are outside application cleanup. Do not search the disk for names containing `dsh` or `DeepSeek`. The success promise concerns application-owned state, not deletion of user work or erasure of OS history.
### 1. Establish ownership and actual paths
Use disposable Windows and macOS accounts to capture filesystem and relevant registration differences across installation, first launch, draft editing, plugin installation, update download, update installation, and uninstallation. Inspect the packaged `package.json`, macOS `Info.plist`, `app-update.yml`, and runtime `app.getPath()` values. Include failed/interrupted updates. Record discovered owners and cleanup locations beside the desktop implementation, with fixture evidence rather than a dynamically accepted list of arbitrary absolute paths.
| Candidate | Windows | macOS | Required treatment |
|---|---|---|---|
| Electron user/session data | Runtime `userData` and `sessionData`, normally under `%APPDATA%` | Runtime `userData` and `sessionData`, normally under `~/Library/Application Support` | Remove browser storage, preferences, cache, and `.updaterId`; disclose loss of unsent drafts. |
| Installer/update cache | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | Remove cached installers, ZIPs, blockmaps, pending downloads, and metadata; include the NSIS installer copy created during installation. |
| Native updater state | Any additional paths proved by the installed NSIS flow | App-specific Squirrel and `<appId>.ShipIt` cache/state/log directories | Stop the owned updater job and remove its remaining state after it exits. |
| Other native application state | App-owned files/registrations proved by observation | App-specific preferences, saved state, logs, or diagnostics proved by observation | Add only established ownership; an Electron path API alone does not prove a file is created. |
| Application and integration | Installation directory, installed shortcuts, installation/uninstallation registration | Selected `.app` and helper-owned integration | Use the native uninstall flow and validate the selected installation identity. |
| Harness home | Default `.dsh` and configured `DSH_HOME` | Default `.dsh` and configured `DSH_HOME` | Preserve the complete trees, including Desktop-only subdirectories. |
Create one small application-owned cleanup inventory, consumed by packaging and cleanup implementations. Derive identity and updater cache names from the release metadata. The same inventory must explain test expectations. Verify any existing distributed release before adding a legacy path; do not introduce migrations for unpublished development layouts.
### 2. Implement shared preservation and cleanup rules
Resolve targets for the installation owner, not the elevated administrator's home. Preserve the default home even when an override is active, and protect configured homes recorded by Desktop as well as the current override. Validate any persisted record at this file boundary; it may protect a location but may never authorize deletion of an arbitrary location.
Reject deletion of a protected home, its ancestor, or its contents. Account for case-insensitive paths, symlinks, Windows junctions/reparse points, and path replacement during cleanup. Do not follow a link into another tree. An installation or cache location containing a protected home is a conflict: stop before recursive removal and report the remaining path. Keep these checks in the actual native removal path, not only in a preview.
Stop new windows, updates, and plugin mutations; finish or safely stop active operations; stop the owned Host/process tree; then wait for exit and released files before removal. Do not kill processes solely by executable name. Perform an ownership/path preflight before destructive steps. Treat missing targets as success, and support retry after partial cleanup. Report locked or inaccessible paths explicitly; a queued reboot deletion or partial result is not complete cleanup.
Keep `.dsh` free of cleanup journals or receipts. Any temporary helper/state must live in a private temporary directory, avoid executable-search-path lookup, and have a bounded self-cleanup path. Verify that cleanup does not recreate Electron user data after it has been removed. Cancellation before removal leaves the installation usable.
### 3. Windows implementation
Keep the assisted NSIS installer (`oneClick: false`). Extend its custom uninstall hooks and removal ordering rather than replacing the complete installer. Do not depend on `deleteAppDataOnUninstall` to cover updater caches, custom paths, or protected homes. Apply preservation checks before the default installation-directory removal as well as before extra cleanup.
Normal Control Panel, Settings, direct uninstaller, and silent uninstall must share the same cleanup rules and useful exit status. Automatic update/replacement must skip destructive user-state cleanup; validate the actual electron-updater flags and NSIS update condition with a real upgrade. A failed upgrade must retain browser data and `.dsh`.
Per-user uninstall cleans that installation owner's Desktop state. All-user uninstall must identify each affected local user profile and clean its Desktop-owned state while preserving every Harness home, under the required Windows privileges. Do not resolve all users through one `$APPDATA` expansion. If an affected profile is inaccessible or another installation still owns a shared path, report that condition instead of deleting shared state or claiming complete cleanup. Include this distinction in the uninstall UI and silent result.
Let NSIS remove the application, its installed shortcuts, and installation registration. Explicitly remove the application-owned cache/user-data inventory, including the cached copy of the installer. Preserve existing signing requirements for installer, uninstaller, and any added executable. Add a build-time check that custom hooks and the cleanup inventory are actually packaged.
### 4. macOS implementation
Add the localized uninstall action to the Electron-owned menu/recovery UI so it remains available when the Host cannot boot. Before proceeding, show that `.dsh` is retained and unsent drafts/UI preferences outside it are deleted. Complete cleanup must include removal of the selected application bundle; if only moving it to Trash, report that disk space remains until Trash is emptied.
Use a signed helper that can finish after Electron and the Host exit. Verify bundle identity and the selected path before removing it; handle `/Applications`, `~/Applications`, and a relocated writable bundle. Obtain authorization only for paths requiring it. Coordinate with Squirrel/ShipIt, remove only this application's updater jobs/state, and delete the discovered external application directories and preferences. Include helper staging files in completion verification.
Package the same cleanup implementation as a signed, notarized standalone uninstaller for the already-deleted-app case. It must not require the removed bundle or a working `.dsh` profile. A read-only mounted DMG is not an installed writable application; provide an actionable result rather than attempting to alter it. For a shared application installation, account for affected users and privileges as on Windows; report any state that could not be cleaned.
### 5. Verification and documentation
Add focused tests for target ownership, protected-home overlap, link/reparse-point traversal, identity mismatch, arbitrary-path rejection, custom/absent `DSH_HOME`, another installation sharing data, and inaccessible profiles. Exercise cancellation, active Host/update/plugin processes, interruption between removals, retries, and self-cleanup without using the developer's real home. Follow the [CI reliability workflow](../../../skills/dsh-ci-test-reliability/SKILL.md) when implementing process and filesystem tests.
Run installed-artifact e2e checks on Windows and both shipped macOS architectures. Cover first installation without updates, one downloaded/installed update, failed update, per-user/shared installation, relocated application, and standalone macOS cleanup after drag deletion. Compare filesystem/registration inventories independently of the uninstaller's success response. Hash retained `.dsh` content after the app and CLI are quiescent and before removal; require identical content, paths, and link entries afterward. Do not require preserved runtime links to resolve after their application target is removed.
Add owner-local expected output for uninstall UI and failure results. Add a keyless recorded-session scenario proving an existing conversation survives uninstall/reinstall; keep purely installer/UI expectations outside the top-level Session tree. Record the required real-server/model GUI GIF for the implementation PR. Update the [Desktop README](../../../../apps/desktop/README.md), its Chinese counterpart, user instructions, locale dictionaries, JSDoc, and this proposal's lifecycle when implementation ships. Select focused checks through [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md); signed packaging and real OS uninstall evidence are required, not replaceable by unit tests.
## Alternatives considered
**Keep the default uninstallers.** They do not establish removal of application-owned state outside the application directory and therefore do not meet the requested outcome.
**Move all Electron and update data under `.dsh`.** This can simplify file placement but preserves caches and browser state instead of cleaning them, and does not by itself cover Squirrel/native state or existing external files. It is not the cleanup solution proposed here.
**Detect Finder deletion with a permanent background watcher.** This adds a resident component that itself needs removal and cannot reliably cover deletion while it is stopped. Use an explicit uninstall operation.
**Delete `.dsh/desktop` or `.dsh/profiles/desktop`.** These paths contain Desktop-specific state, but the requested retention rule preserves the whole Harness home rather than only conversations.
## Acceptance criteria
- A successful supported uninstall removes the selected application, its owned registrations, and every external application-owned path established by the installed-artifact inventory; no cleanup helper or updater process remains.
- Default and configured Harness homes retain identical content and links after quiescence. User projects and independent CLI installations remain intact.
- Windows Control Panel/Settings and silent uninstall satisfy the same retention rule. An actual automatic upgrade retains browser state and Harness data.
- macOS's explicit and standalone uninstallers satisfy the cleanup rule. Documentation accurately distinguishes them from Finder drag deletion and from merely moving files to Trash.
- Every unremoved owned path is reported with a retry/recovery action. Unavailable privileges, an inaccessible user profile, and shared ownership never produce a false full-success result.
- Fresh install and uninstall/reinstall tests prove no untracked outside-home application state survives and that retained conversations can be reopened. Native OS inventories, not only mocked API calls, establish completion.
## Risks
Deleting browser storage loses unsent drafts and UI preferences. Whole-home preservation deliberately retains Desktop plugins, pnpm state, and possibly dangling links until reinstall repairs them. Custom data paths and shared installations can prevent complete removal without additional privileges or path relocation; the operation must report that limitation rather than violate retention.
OS-owned security/audit history and user-created files cannot be covered by a promise of erasing all traces. The [Desktop packaging decision](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.md) and [bundled runtime/external plugin decision](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.md) remain independent authorities for packaging and data ownership; this proposal supersedes neither and archives no active note.
@@ -1,95 +0,0 @@
# Agent Note: 卸载 Desktop 并保留 Harness 主目录
Status: proposed
[English](2026-09-08-desktop-uninstall-preserve-dsh-home.md) | 中文
## 问题
用户需要 Desktop 卸载删除应用文件及外部应用状态,同时保留完整的 Harness 主目录。删除应用目录并不能覆盖浏览器存储、安装器缓存或原生更新器状态。浏览器存储还包含未发送草稿和界面偏好,因此清理它不只是释放磁盘缓存。
[打包配置](../../../../apps/desktop/electron-builder.config.mjs)、[桌面入口](../../../../apps/desktop/src/main.ts)和[更新协调器](../../../../apps/desktop/src/update-coordinator.ts)是代码检查依据。源码检查用于识别清理候选项;实现验收前,必须通过已安装产物的观察确定受支持的完整清单。目前尚未通过卸载实验建立完整的 Windows 或 macOS 清单。
## 提案
提供卸载操作:成功后保留完整的默认 `~/.dsh` 和已配置的 `DSH_HOME`,删除这些主目录之外由 Desktop 拥有的文件,并移除选定的应用安装。保留主目录内的插件文件、包管理器状态、会话、设置和凭据。清理期间不得启动或修复 Desktop profile。
Windows 控制面板和设置中的卸载将通过 NSIS 执行此操作。macOS 将提供本地化的**卸载并保留 Harness 数据**操作,由签名的清理辅助程序完成;单纯通过 Finder 拖进废纸篓不能调用此操作,文档必须说明这一限制,并将同一辅助程序作为签名的独立卸载器提供给已经删除应用的用户。不得把单纯拖拽宣传为完整清理。
用户创建的项目、单独安装的 CLI(命令行界面)软件、用户保存的下载文件和导出文件、任意第三方插件或工具的输出,以及操作系统维护的审计和安全记录,不属于应用清理范围。不得按文件名包含 `dsh` 或 `DeepSeek` 全盘搜索。成功承诺针对应用拥有的状态,不包括删除用户工作成果或抹除系统历史。
### 1. 确定归属和实际路径
使用一次性 Windows 和 macOS 账户,记录安装、首次启动、编辑草稿、安装插件、下载更新、安装更新及卸载前后的文件系统和相关注册信息差异。检查打包后的 `package.json`、macOS `Info.plist`、`app-update.yml` 和运行时 `app.getPath()` 值。覆盖更新失败和中断。将发现的归属与清理位置记录在桌面实现附近,提供 fixture(测试前置数据)证据,不采用动态接收任意绝对路径的清单。
| 候选项 | Windows | macOS | 处理要求 |
|---|---|---|---|
| Electron 用户及会话数据 | 运行时 `userData` 和 `sessionData`,通常位于 `%APPDATA%` 下 | 运行时 `userData` 和 `sessionData`,通常位于 `~/Library/Application Support` 下 | 删除浏览器存储、偏好、缓存和 `.updaterId`;说明未发送草稿会丢失。 |
| 安装器及更新缓存 | `%LOCALAPPDATA%/<updaterCacheDirName>` | `~/Library/Caches/<updaterCacheDirName>` | 删除安装器副本、ZIP、blockmap、待处理下载和元数据;包括 NSIS 安装期间创建的安装器缓存副本。 |
| 原生更新器状态 | 通过实际安装的 NSIS 流程证实的其他路径 | 应用专属的 Squirrel 和 `<appId>.ShipIt` 缓存、状态及日志目录 | 停止所属更新任务,等待退出后删除残留状态。 |
| 其他原生应用状态 | 观察证实由应用拥有的文件和注册信息 | 观察证实的应用专属偏好、窗口恢复状态、日志或诊断数据 | 只加入归属已确定的项目;存在 Electron 路径 API 不足以证明文件会产生。 |
| 应用及系统集成 | 安装目录、安装的快捷方式、安装和卸载注册信息 | 选定的 `.app` 及辅助程序拥有的系统集成 | 使用原生卸载流程,并验证选定安装的身份。 |
| Harness 主目录 | 默认 `.dsh` 和已配置的 `DSH_HOME` | 默认 `.dsh` 和已配置的 `DSH_HOME` | 保留完整目录树,包括 Desktop 专属子目录。 |
建立一份精简的应用清理清单,由打包和清理实现共同使用。应用身份与更新缓存名称从发布元数据推导。同一清单必须对应测试期望。加入旧路径前先核查实际已分发版本,不为未发布的开发布局增加迁移。
### 2. 实现共用的保留与清理规则
按安装所属用户解析目标,不能使用提权管理员的主目录。即使覆盖路径已启用,也保留默认主目录;同时保护 Desktop 已记录的配置主目录和当前覆盖路径。持久化记录在文件边界进行校验;记录可以保护位置,但绝不能授权删除任意位置。
拒绝删除受保护主目录、其祖先目录或其内容。处理路径大小写不敏感、符号链接、Windows junction/reparse point,以及清理期间路径被替换的情况。不得沿链接进入其他目录树。如果安装或缓存目录包含受保护主目录,则视为冲突:递归删除前停止,并报告剩余路径。这些检查必须位于实际原生删除流程中,不能只放在预览阶段。
阻止新窗口、更新和插件变更;完成或安全停止正在进行的操作;停止所属 Host 和进程树;等待进程退出并释放文件后再删除。不得仅凭可执行文件名终止进程。破坏性步骤前先检查归属和路径。目标不存在视为成功,部分清理后允许重试。明确报告被占用或无法访问的路径;已安排重启后删除或部分完成,不等于完整清理。
不得在 `.dsh` 写入清理日志或回执。临时辅助程序和状态必须位于私有临时目录,避免通过可执行文件搜索路径寻找程序,并具有有界的自行清理流程。验证清理不会在删除后重新创建 Electron 用户数据。删除开始前取消操作应保持安装可用。
### 3. Windows 实现
保留向导式 NSIS 安装器(`oneClick: false`)。扩展自定义卸载钩子和删除顺序,不替换整个安装器。不得依赖 `deleteAppDataOnUninstall` 覆盖更新缓存、自定义路径或主目录保护。在默认安装目录删除和额外清理前都应用保留检查。
正常控制面板、设置、直接运行卸载器和静默卸载必须共用清理规则,并返回有用的退出状态。自动更新或替换安装必须跳过破坏性的用户状态清理;通过实际升级验证 electron-updater 参数和 NSIS 更新条件。升级失败必须保留浏览器数据和 `.dsh`。
按用户安装的卸载清理该安装所属用户的 Desktop 状态。全用户卸载必须识别每个受影响的本地用户配置目录,在具备所需 Windows 权限的情况下清理其中由 Desktop 拥有的状态,并保留每个 Harness 主目录。不能用一次 `$APPDATA` 展开代表所有用户。如果受影响的用户目录无法访问,或另一个安装仍拥有共享路径,应报告该情况,不得删除共享状态或宣称完整清理。卸载界面和静默执行结果都要体现这一区别。
由 NSIS 删除应用、安装的快捷方式和安装注册信息。显式删除清单中的应用缓存和用户数据,包括缓存的安装器副本。维持安装器、卸载器及新增可执行文件的签名要求。增加构建期检查,证明自定义钩子和清理清单确实进入产物。
### 4. macOS 实现
在 Electron 拥有的菜单和恢复界面加入本地化卸载操作,保证 Host 无法启动时仍可使用。执行前说明保留 `.dsh`,并删除主目录外的未发送草稿和界面偏好。完整清理必须包括移除选定应用包;如果只是移入废纸篓,应说明清空废纸篓前仍占用磁盘。
使用签名的辅助程序,在 Electron 和 Host 退出后完成清理。删除前验证应用包身份和选定路径;覆盖 `/Applications`、`~/Applications` 及移动后的可写应用包。仅对需要权限的路径请求授权。协调 Squirrel/ShipIt,只移除本应用的更新任务和状态,再删除已识别的外部应用目录及偏好。完成验证必须包括辅助程序的临时文件。
把同一清理实现打包为签名、公证的独立卸载器,支持应用已被删除的情况。它不能依赖已删除的应用包或可启动的 `.dsh` profile。只读挂载的 DMG 不属于已安装的可写应用;应给出可操作的结果,不能尝试修改它。共享应用安装和 Windows 一样需要处理受影响用户及权限,并报告未能清理的状态。
### 5. 验证和文档
增加针对性测试,覆盖目标归属、受保护主目录重叠、链接和 reparse point 遍历、身份不匹配、任意路径拒绝、自定义或未设置的 `DSH_HOME`、另一安装共享数据,以及无法访问的用户配置目录。覆盖取消、活跃 Host/更新/插件进程、删除步骤间中断、重试及自行清理,禁止使用开发者真实主目录。实现进程和文件系统测试时遵循 [CI 可靠性工作流](../../../skills/dsh-ci-test-reliability/SKILL.md)。
在 Windows 和两个已发布 macOS 架构上执行已安装产物的 e2e 检查。覆盖首次安装且未更新、下载并安装一次更新、更新失败、按用户或共享安装、移动应用,以及拖拽删除后的独立 macOS 清理。独立比较文件系统和注册信息清单,不以卸载器的成功响应代替验证。应用和 CLI 静止后、删除前对保留的 `.dsh` 内容计算哈希;要求删除后内容、路径和链接条目完全一致。应用目标被删除后,不要求保留的运行时链接仍可解析。
为卸载界面和失败结果增加归属模块旁的预期输出。增加无密钥的录制会话场景,证明已有会话能跨卸载和重装保留;纯安装器或界面预期不放入顶层 Session 树。为实现 PR(Pull Request)录制规则要求的真实服务器和模型 GUI GIF。实现交付时更新 [Desktop README](../../../../apps/desktop/README.zh.md)、英文对应文档、用户说明、语言字典、JSDoc 和本提案的生命周期。通过 [dsh-pre-push-checks](../../../skills/dsh-pre-push-checks/SKILL.md) 选择针对性检查;签名打包和真实操作系统卸载证据为必需项,不能由单元测试替代。
## 考虑过的替代方案
**维持默认卸载器。** 它们不能证明应用目录之外的应用状态已被移除,因此不满足要求。
**把全部 Electron 和更新数据移到 `.dsh`。** 这能简化文件位置,但会保留缓存和浏览器状态而不是清理它们,而且自身不能覆盖 Squirrel/原生状态或已有的外部文件。本提案不采用它作为清理方案。
**通过常驻后台监视器检测 Finder 删除。** 这会增加一个本身也需要卸载的常驻组件,并且不能可靠覆盖监视器停止期间发生的删除。采用显式卸载操作。
**删除 `.dsh/desktop` 或 `.dsh/profiles/desktop`。** 这些路径包含 Desktop 专属状态,但要求保留的是整个 Harness 主目录,而不只是会话。
## 验收标准
- 受支持的卸载成功后,选定应用、它拥有的注册信息,以及已安装产物清单确定的每个外部应用路径均已移除;没有清理辅助程序或更新器进程残留。
- 静止后,默认和已配置 Harness 主目录的内容及链接完全保留。用户项目和独立 CLI 安装保持完整。
- Windows 控制面板、设置及静默卸载满足同一保留规则。实际自动升级保留浏览器状态和 Harness 数据。
- macOS 显式操作和独立卸载器满足清理规则。文档准确区分它们与 Finder 拖拽删除,以及仅移入废纸篓的情况。
- 每个未移除的所属路径都报告重试或恢复方式。权限不足、用户配置目录不可访问和共享归属均不得产生虚假的完整成功结果。
- 首次安装及卸载重装测试证明,没有未纳入清单的主目录外应用状态残留,且能重新打开保留的会话。通过原生操作系统清单验证完成,不能只依赖模拟 API 调用。
## 风险
删除浏览器存储会丢失未发送草稿和界面偏好。完整保留主目录意味着有意保留 Desktop 插件、pnpm 状态,以及可能在重装修复前悬空的链接。自定义数据路径和共享安装可能需要额外权限或移动路径才能完整删除;操作必须报告限制,不能违反保留规则。
操作系统拥有的安全和审计历史,以及用户创建的文件,不能被“抹除所有痕迹”的承诺覆盖。[Desktop 打包决策](../../implemented/architecture/2026-08-25-electron-desktop-packaging-and-updates.zh.md)和[内置运行时与外部插件决策](../../implemented/architecture/2026-09-08-desktop-bundled-runtime-and-external-plugins.zh.md)继续分别拥有打包和数据归属规则;本提案不取代这两份记录,也不归档任何活跃记录。
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: e99f771653c62175655f12d6982c04e5f91055d1
README.zh.md: 9c7f86e5ab1ad0e42fc0fe2ea643c6ca4bed392f
README.md: 0b22cc7325cea78e11df197dc3ccdc0210e21b32
README.zh.md: 1d4d807c4b263726ee58fc3c3a0066d2eb15bbb7
+4
View File
@@ -240,6 +240,10 @@ Windows packaging compiles an x86 Win32/GDI+ helper with Visual C++ Build Tools
Run `pnpm --dir apps/desktop run test:installer` from the repository root on an interactive Windows x64 desktop to build and exercise a small native test payload through the production installer configuration. Each run uses a unique product identity and sequentially exercises English-only and Chinese-only installer variants, selecting test labels from the displayed welcome button. Both variants install into private directories and uninstall after testing; screenshots and results remain under `.desktop-build/installer-tests/`. The checks include upgrades to registered paths with trailing separators and rejection of drive roots. The optional `--signed` flag uses the Windows EV configuration below to sign test executables and the helper before embedding them; it does not enable an update feed.
The Windows uninstaller removes the Electron user-data directory (browser storage and caches under `%APPDATA%`, nested under the package scope), the `%APPDATA%` product directory, and the updater download cache under `%LOCALAPPDATA%` together with the application, then removes ordinary empty scope directories below `%APPDATA%`. The Harness home (`~/.dsh` or `DSH_HOME`: sessions, settings, credentials, plugins) is never touched, and a `DSH_HOME` published as a Windows environment variable additionally protects any target overlapping it. Silent uninstalls remove the same data; an uninstaller started with `--updated` or `/KEEP_APP_DATA`, as electron-builder does for in-place updates and for replacing an older installation from another directory, retains it. Removal runs through the native helper, which refuses protected Windows folders and paths overlapping the installation or the home, requires a fixed local drive, leaves a linked root or ancestor in place, unlinks reparse points without entering their targets, clears read-only attributes, and keeps deleting siblings after a locked file; residue never stops uninstallation and is not reported. Installation records `InstallLocation` on the Windows uninstall entry as standard inventory metadata; on Windows 11, Uninstall in the Start menu context menu opens the installed-apps list for every Win32 application, and only MSIX packages uninstall directly from there. The uninstaller declares DPI awareness and uses Microsoft YaHei UI for Chinese. This behavior is Windows-only.
Use `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` to compile isolated English and Chinese fixtures with a per-run scoped package name. Omit `--compile-only` to run the native remover regression and the interactive, silent, `--updated`, `/KEEP_APP_DATA` and `DSH_HOME`-inside-Electron-data checks against seeded data. Compilation does not establish installed-uninstall behavior.
### Windows EV signing
Runtime signing shares complete signed files across the current Windows account's worktrees. `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` in `.env.windows` selects an absolute fixed-drive directory; the default is `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`. Cache directories must belong to the current account and exclude other ordinary accounts from their access permissions; linked paths are rejected. Entries identify the original bytes, public certificate and signing toolchain. Every restoration checks the digest, Windows trust, timestamp and certificate before replacing the unsigned file; invalid entries stop packaging without hardware fallback. Cache age alone does not trigger signing. The cache trusts programs running as the same account and does not defend against administrators. The [runtime signature cache decision](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.md) owns qualification and design limits.
+4
View File
@@ -240,6 +240,10 @@ Windows 打包使用 Visual C++ Build Tools 和 Windows SDK 编译 x86 Win32/GDI
在有交互式桌面的 Windows x64 上,从仓库根目录运行 `pnpm --dir apps/desktop run test:installer`,可将小型原生测试载荷接入正式安装配置并执行验证。每次运行使用独立产品身份,依次验证仅英文和仅中文的安装器变体,并根据实际显示的欢迎页按钮选择测试文案。两个变体均安装到私有目录并在测试后卸载;截图和结果保留在 `.desktop-build/installer-tests/` 下。检查包含末尾带分隔符的已登记路径升级,以及磁盘根目录拒绝。可选的 `--signed` 标志使用下文的 Windows EV 配置,在嵌入前对测试程序和辅助库签名;它不会启用更新源。
Windows 卸载程序会随应用一起删除 Electron 用户数据目录(`%APPDATA%` 下按包作用域嵌套的浏览器存储与缓存)、`%APPDATA%` 下的产品目录,以及 `%LOCALAPPDATA%` 下的更新下载缓存,随后移除 `%APPDATA%` 之下普通且已空的作用域目录。Harness 主目录(`~/.dsh` 或 `DSH_HOME`:会话、设置、凭据、插件)不会被触碰;以 Windows 环境变量发布的 `DSH_HOME` 还会保护所有与其重叠的目标。静默卸载删除相同的数据;以 `--updated` 或 `/KEEP_APP_DATA` 启动的卸载程序保留数据,electron-builder 在原地更新和从其他目录替换旧安装时正是这样启动它。删除通过原生辅助程序执行:它拒绝受保护的 Windows 目录以及与安装目录或主目录重叠的路径,要求固定的本地驱动器,链接的根目录或祖先目录原样保留,遇到重解析点只解除链接而不进入目标,清除只读属性,并在遇到被占用文件后继续删除其余兄弟项;残余不会中止卸载,也不会提示。安装时在 Windows 卸载注册项上记录 `InstallLocation` 作为标准的清单元数据;在 Windows 11 上,开始菜单右键菜单中的“卸载”对所有 Win32 应用都会打开已安装应用列表,只有 MSIX 包能从那里直接卸载。卸载程序声明 DPI 感知,中文使用微软雅黑 UI。此行为仅适用于 Windows。
使用 `node apps/desktop/scripts/test-windows-installer.mjs --uninstall-only --compile-only` 以每次运行唯一的带作用域包名编译独立的中英文夹具。省略 `--compile-only` 可对预置数据运行原生删除器回归,以及交互、静默、`--updated`、`/KEEP_APP_DATA` 和 `DSH_HOME` 位于 Electron 数据内的检查。编译本身不能证明已安装卸载行为。
### Windows EV 签名
运行时签名在当前 Windows 账户的各 worktree 间共享完整的已签名文件。`.env.windows` 中的 `DSH_DESKTOP_WINDOWS_SIGNATURE_CACHE_DIR` 指定固定本地磁盘上的绝对目录;默认值为 `%USERPROFILE%\.dsh-desktop-signing\signature-cache\v1`。缓存目录必须属于当前账户,访问权限不得向其他普通账户开放;带链接的路径会被拒绝。缓存项标识原始字节、公钥证书和签名工具链。每次恢复都检查摘要、Windows 信任状态、时间戳和证书,再替换未签名文件;缓存项无效会停止打包,不回退到硬件签名。不会仅因缓存较旧而重新签名。缓存信任同账户运行的程序,不防御管理员。[运行时签名缓存决策](../../.agents/notes/implemented/process/2026-09-17-windows-runtime-signature-cache.zh.md)定义验收要求和设计限制。
+167
View File
@@ -0,0 +1,167 @@
// Windows data cleanup: protect shell roots and pin directory names while enumerating.
#pragma once
#include <shlobj.h>
#include <string>
#include <vector>
namespace uninstall_data {
struct Handles {
std::vector<HANDLE> values;
Handles() = default;
Handles(const Handles&) = delete;
Handles& operator=(const Handles&) = delete;
~Handles() { for (HANDLE value : values) CloseHandle(value); }
};
inline bool Contains(const std::wstring& parent, const std::wstring& child) {
return _wcsicmp(parent.c_str(), child.c_str()) == 0
|| (child.size() > parent.size() && child[parent.size()] == L'\\'
&& _wcsnicmp(parent.c_str(), child.c_str(), parent.size()) == 0);
}
inline bool Normalize(LPCWSTR input, std::wstring& result) {
if (!input || wcslen(input) < 4 || input[1] != L':' || input[2] != L'\\') return false;
if (wcspbrk(input + 3, L"/:*?\"<>|\r\n\t")) return false;
WCHAR buffer[32768];
DWORD length = GetFullPathNameW(input, ARRAYSIZE(buffer), buffer, nullptr);
if (!length || length >= ARRAYSIZE(buffer)) return false;
result.assign(buffer, length);
length = GetLongPathNameW(result.c_str(), buffer, ARRAYSIZE(buffer));
if (length > 0 && length < ARRAYSIZE(buffer)) result.assign(buffer, length);
while (result.size() > 3 && result.back() == L'\\') result.pop_back();
if (result.size() <= 3 || GetDriveTypeW(result.substr(0, 3).c_str()) != DRIVE_FIXED) return false;
for (size_t start = 3; start < result.size();) {
size_t end = result.find(L'\\', start);
if (end == std::wstring::npos) end = result.size();
if (end == start || result[end - 1] == L'.' || result[end - 1] == L' ') return false;
start = end + 1;
}
return true;
}
// Listing access takes part in share checks, so the open handle refuses the DELETE access a rename or removal needs.
inline DWORD Pin(const std::wstring& path, Handles& handles) {
HANDLE handle = CreateFileW((L"\\\\?\\" + path).c_str(), FILE_LIST_DIRECTORY | FILE_READ_ATTRIBUTES,
FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT, nullptr);
if (handle == INVALID_HANDLE_VALUE) return GetLastError();
handles.values.push_back(handle);
BY_HANDLE_FILE_INFORMATION info{};
if (!GetFileInformationByHandle(handle, &info)) return GetLastError();
if (!(info.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)
|| (info.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) return ERROR_ACCESS_DENIED;
return ERROR_SUCCESS;
}
inline DWORD Unlink(const std::wstring& extended, DWORD attributes) {
if (attributes & FILE_ATTRIBUTE_READONLY) SetFileAttributesW(extended.c_str(), FILE_ATTRIBUTE_NORMAL);
const BOOL removed = (attributes & FILE_ATTRIBUTE_DIRECTORY)
? RemoveDirectoryW(extended.c_str()) : DeleteFileW(extended.c_str());
return removed ? ERROR_SUCCESS : GetLastError();
}
// Every sibling is attempted; the first error is returned after the whole tree has been visited.
inline DWORD RemoveTree(const std::wstring& path) {
const std::wstring extended = L"\\\\?\\" + path;
DWORD attributes = GetFileAttributesW(extended.c_str());
if (attributes == INVALID_FILE_ATTRIBUTES) {
DWORD error = GetLastError();
return error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND ? ERROR_SUCCESS : error;
}
DWORD first = ERROR_SUCCESS;
if ((attributes & FILE_ATTRIBUTE_DIRECTORY) && !(attributes & FILE_ATTRIBUTE_REPARSE_POINT)) {
Handles pinned;
DWORD error = Pin(path, pinned);
if (error) return error;
WIN32_FIND_DATAW entry{};
HANDLE search = FindFirstFileW((extended + L"\\*").c_str(), &entry);
if (search == INVALID_HANDLE_VALUE) {
error = GetLastError();
if (error != ERROR_FILE_NOT_FOUND) return error;
} else {
do {
if (wcscmp(entry.cFileName, L".") == 0 || wcscmp(entry.cFileName, L"..") == 0) continue;
error = RemoveTree(path + L"\\" + entry.cFileName);
if (error && !first) first = error;
} while (FindNextFileW(search, &entry));
error = GetLastError();
FindClose(search);
if (error != ERROR_NO_MORE_FILES && !first) first = error;
}
}
DWORD error = Unlink(extended, attributes);
return first ? first : error;
}
// Never select a shell folder, its ancestor, Windows/Program Files descendants,
// a path overlapping the installation, or a path overlapping the protected root
// (the configured Harness home). Linked descendants are unlinked only.
inline DWORD Remove(LPCWSTR input, LPCWSTR installation, LPCWSTR protectedRoot) {
std::wstring path, install;
if (!Normalize(input, path) || !Normalize(installation, install)) return ERROR_INVALID_NAME;
if (Contains(path, install) || Contains(install, path)) return ERROR_ACCESS_DENIED;
if (protectedRoot && *protectedRoot) {
std::wstring home;
// An unusable protected root cannot establish separation from retained data.
if (!Normalize(protectedRoot, home)) return ERROR_INVALID_NAME;
if (Contains(path, home) || Contains(home, path)) return ERROR_ACCESS_DENIED;
}
struct ComScope {
HRESULT result = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
~ComScope() { if (SUCCEEDED(result)) CoUninitialize(); }
} com;
if (FAILED(com.result) && com.result != RPC_E_CHANGED_MODE) return ERROR_ACCESS_DENIED;
// This helper is x86; the x64 Program Files known-folder id is unavailable to it.
WCHAR nativePrograms[32768];
DWORD bytes = sizeof(nativePrograms);
if (RegGetValueW(HKEY_LOCAL_MACHINE, L"SOFTWARE\\Microsoft\\Windows\\CurrentVersion", L"ProgramFilesDir",
RRF_RT_REG_SZ | RRF_SUBKEY_WOW6464KEY, nullptr, nativePrograms, &bytes) != ERROR_SUCCESS) return ERROR_ACCESS_DENIED;
std::wstring nativeFolder;
if (!Normalize(nativePrograms, nativeFolder) || Contains(path, nativeFolder) || Contains(nativeFolder, path)) return ERROR_ACCESS_DENIED;
const KNOWNFOLDERID* protectedFolders[] = {&FOLDERID_Profile, &FOLDERID_RoamingAppData,
&FOLDERID_LocalAppData, &FOLDERID_Desktop, &FOLDERID_Documents, &FOLDERID_Downloads,
&FOLDERID_Pictures, &FOLDERID_Music, &FOLDERID_Videos, &FOLDERID_Public, &FOLDERID_ProgramData,
&FOLDERID_Windows, &FOLDERID_ProgramFiles, &FOLDERID_ProgramFilesX86};
for (const KNOWNFOLDERID* id : protectedFolders) {
PWSTR value = nullptr;
HRESULT result = SHGetKnownFolderPath(*id, KF_FLAG_DONT_VERIFY, nullptr, &value);
if (FAILED(result)) return ERROR_ACCESS_DENIED;
// Known-folder paths are already full paths; a redirected folder on another volume must stay protected.
const std::wstring folder(value);
CoTaskMemFree(value);
if (Contains(path, folder)) return ERROR_ACCESS_DENIED;
if ((id == &FOLDERID_Windows || id == &FOLDERID_ProgramFiles || id == &FOLDERID_ProgramFilesX86)
&& Contains(folder, path)) return ERROR_ACCESS_DENIED;
}
Handles parents;
for (size_t end = path.find(L'\\', 3); end != std::wstring::npos; end = path.find(L'\\', end + 1)) {
DWORD error = Pin(path.substr(0, end), parents);
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) return ERROR_SUCCESS;
if (error) return error;
}
// A selected root must be a real directory, even though links inside it may be removed.
{
Handles root;
DWORD error = Pin(path, root);
if (error == ERROR_FILE_NOT_FOUND || error == ERROR_PATH_NOT_FOUND) return ERROR_SUCCESS;
if (error) return error;
}
return RemoveTree(path);
}
// Remove the ordinary, empty directories between a removed path and its stop ancestor.
// A linked or populated parent ends the walk; the stop directory itself is never removed.
inline DWORD RemoveEmptyParents(LPCWSTR input, LPCWSTR stopInput) {
std::wstring path, stop;
if (!Normalize(input, path) || !Normalize(stopInput, stop)) return ERROR_INVALID_NAME;
if (!Contains(stop, path) || _wcsicmp(stop.c_str(), path.c_str()) == 0) return ERROR_ACCESS_DENIED;
for (size_t end = path.rfind(L'\\'); end != std::wstring::npos && end > stop.size(); end = path.rfind(L'\\', end - 1)) {
const std::wstring parent = L"\\\\?\\" + path.substr(0, end);
const DWORD attributes = GetFileAttributesW(parent.c_str());
if (attributes == INVALID_FILE_ATTRIBUTES) return GetLastError();
if (!(attributes & FILE_ATTRIBUTE_DIRECTORY) || (attributes & FILE_ATTRIBUTE_REPARSE_POINT)) return ERROR_ACCESS_DENIED;
if (!RemoveDirectoryW(parent.c_str())) return GetLastError();
}
return ERROR_SUCCESS;
}
}
+36
View File
@@ -0,0 +1,36 @@
; Electron user data and updater downloads leave with the application; the Harness home is never touched.
!include FileFunc.nsh
Var UnTarget
Var UnHome
; The helper refuses unsafe roots, the installation and the Harness home, and never descends into reparse points;
; a locked file leaves residue and never fails the uninstall. customCheckAppRunning extracts window-frame.dll first.
Function un.RemoveData
System::Call '$PLUGINSDIR\window-frame.dll::UninstallRemoveData(w "$UnTarget", w "$INSTDIR", w "$UnHome") ?c'
FunctionEnd
Function un.CleanData
${If} ${isUpdated}
Return
${EndIf}
; The installer runs an older uninstaller with /KEEP_APP_DATA when replacing it from another directory.
${GetParameters} $R0
ClearErrors
${GetOptions} $R0 "/KEEP_APP_DATA" $R1
${IfNot} ${Errors}
Return
${EndIf}
; Only a DSH_HOME published as a Windows environment variable is visible here.
ReadEnvStr $UnHome DSH_HOME
ClearErrors
StrCpy $UnTarget "$APPDATA\${PRODUCT_FILENAME}"
Call un.RemoveData
!ifdef APP_PACKAGE_NAME
; Electron derives user data from the package name; a scoped name nests it one directory deeper.
StrCpy $UnTarget "$APPDATA\${APP_PACKAGE_NAME}"
Call un.RemoveData
System::Call '$PLUGINSDIR\window-frame.dll::UninstallRemoveEmptyParents(w "$UnTarget", w "$APPDATA") ?c'
!endif
StrCpy $UnTarget "$LOCALAPPDATA\${DSH_UPDATER_CACHE_NAME}"
Call un.RemoveData
FunctionEnd
+11
View File
@@ -12,9 +12,20 @@
#include <algorithm>
#include "progress.h"
#include "extract.h"
#include "uninstall-data.h"
using namespace Gdiplus;
// Returns a Win32 error code; no cleanup request may traverse a linked root or touch the protected root.
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveData(LPCWSTR path, LPCWSTR installation, LPCWSTR protectedRoot) {
return uninstall_data::Remove(path, installation, protectedRoot);
}
// Returns a Win32 error code; only ordinary empty directories strictly below the stop directory are removed.
extern "C" __declspec(dllexport) DWORD __cdecl UninstallRemoveEmptyParents(LPCWSTR path, LPCWSTR stop) {
return uninstall_data::RemoveEmptyParents(path, stop);
}
// Match the affected executable, not another user's or directory's same-named application.
// Returns 0 while running, 1 when absent, and -1 if the process list cannot be read.
extern "C" __declspec(dllexport) int __cdecl InstallerFindProcess(LPCWSTR executable) {
+15 -1
View File
@@ -2,14 +2,22 @@
!define INSTALLER_SOURCE_DIR "${__FILEDIR__}\..\installer"
!define /ifndef INSTALLER_BUILD_DIR "${__FILEDIR__}\..\.desktop-build\targets\win-x64\installer-ui"
ManifestDPIAware true
!ifndef BUILD_UNINSTALLER
ManifestDPIAware true
!define MUI_CUSTOMFUNCTION_GUIINIT InstallerGuiInit
!endif
!macro customHeader
!define /ifndef INSTALLER_STRINGS_FILE "${INSTALLER_SOURCE_DIR}\strings.nsh"
!include "${INSTALLER_STRINGS_FILE}"
!ifdef BUILD_UNINSTALLER
BrandingText " "
SetFont "Segoe UI" 9
!ifdef LANG_SIMPCHINESE
SetFont /LANG=${LANG_SIMPCHINESE} "Microsoft YaHei UI" 9
!endif
!include "${INSTALLER_SOURCE_DIR}\uninstall.nsh"
!endif
!ifndef BUILD_UNINSTALLER
!include "${INSTALLER_SOURCE_DIR}\theme.nsh"
!include "${INSTALLER_SOURCE_DIR}\pages.nsh"
@@ -74,6 +82,10 @@
Page custom InstallerWelcome InstallerWelcomeLeave
!macroend
!macro customUnInstall
Call un.CleanData
!macroend
!macro customPageAfterChangeDir
!define MUI_PAGE_CUSTOMFUNCTION_PRE InstallerBeforeInstall
!define MUI_PAGE_CUSTOMFUNCTION_SHOW InstallerProgressShow
@@ -163,6 +175,8 @@
${EndIf}
!insertmacro InstallerPublishStage 4
!insertmacro dshFinishDirectories
; Standard uninstall-entry metadata read by inventory tools; the upstream template records it only under its private key.
WriteRegStr SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}" InstallLocation "$INSTDIR"
${If} $0 == 1
SetErrors
${Else}
@@ -14,11 +14,22 @@ $visualStudio = & $vswhere -latest -products '*' -requires Microsoft.VisualStudi
if (-not $visualStudio) { throw 'Visual Studio C++ Build Tools are missing.' }
$vcvars = Join-Path $visualStudio 'VC/Auxiliary/Build/vcvars32.bat'
$compileScript = Join-Path $output 'compile-frame.cmd'
$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
$compileLines = @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /LD /MT /O1 /W4 /WX /EHsc "{0}" /Fo"{1}" /link /OUT:"{2}" /IMPLIB:"{3}" user32.lib comctl32.lib dwmapi.lib gdiplus.lib ole32.lib shell32.lib uuid.lib advapi32.lib' -f $source, (Join-Path $output 'window-frame.obj'), $library, (Join-Path $output 'window-frame.lib')))
[IO.File]::WriteAllLines($compileScript, $compileLines, [Text.Encoding]::Default)
& $env:ComSpec /d /c $compileScript
if ($LASTEXITCODE -ne 0) { throw 'Native installer helper compilation failed.' }
if ($TestProgress -or $CompileProgressOnly) {
$cleanupSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-uninstall-data.cpp'))
# UAC installer detection demands elevation from unmanifested executables named after installation.
$cleanupExecutable = Join-Path $output 'data-cleanup-test.exe'
$cleanupScript = Join-Path $output 'compile-data-cleanup-test.cmd'
[IO.File]::WriteAllLines($cleanupScript, @('@echo off', ('call "{0}" >nul' -f $vcvars), 'if errorlevel 1 exit /b %errorlevel%', ('cl /nologo /MT /W4 /WX /EHsc "{0}" /Fo"{1}" /Fe"{2}" /link shell32.lib ole32.lib uuid.lib advapi32.lib' -f $cleanupSource, (Join-Path $output 'data-cleanup-test.obj'), $cleanupExecutable)), [Text.Encoding]::Default)
& $env:ComSpec /d /c $cleanupScript
if ($LASTEXITCODE -ne 0) { throw 'Uninstall data test compilation failed.' }
if ($TestProgress) {
& $cleanupExecutable (Join-Path $output ('cleanup-' + [Guid]::NewGuid().ToString('N')))
if ($LASTEXITCODE -ne 0) { throw 'Uninstall data regression failed.' }
}
$testSource = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '../tests/windows-installer-progress.cpp'))
$testExecutable = Join-Path $output 'progress-test.exe'
$testScript = Join-Path $output 'compile-progress-test.cmd'
+22 -10
View File
@@ -21,6 +21,9 @@ const { getMakeNsisPath } = require('app-builder-lib/out/toolsets/windows.js')
const guid = randomUUID()
const id = guid.replaceAll('-', '')
const productName = `Harness Installer Test ${id.slice(0, 8)}`
// Scoped like the shipped package so Electron user data nests under a scope directory; the scope is unique per run.
const packageName = `@harness-installer-test-${id.slice(0, 8)}/app-${id}`
const uninstallOnly = process.argv.includes('--uninstall-only')
const outputRoot = join(appRoot, '.desktop-build', 'installer-tests')
await mkdir(outputRoot, { recursive: true })
const output = await mkdtemp(join(outputRoot, 'run-'))
@@ -55,14 +58,19 @@ try {
join(appRoot, 'scripts', 'prepare-windows-installer.ps1'), '-OutputDirectory', join(output, 'ui'), '-CompileProgressOnly'], childOptions)
const progressTest = join(output, 'ui', 'progress-test.exe')
const presentationTest = join(output, 'ui', 'presentation-test.exe')
const cleanupTest = join(output, 'ui', 'data-cleanup-test.exe')
if (sign) {
await sign({ path: progressTest, hash: 'sha256', isNest: false })
await sign({ path: presentationTest, hash: 'sha256', isNest: false })
await sign({ path: cleanupTest, hash: 'sha256', isNest: false })
await sign({ path: join(output, 'ui', 'window-frame.dll'), hash: 'sha256', isNest: false })
installWindowsNsisBootstrapSigner({ sign })
}
await execute(progressTest, [], childOptions)
await execute(presentationTest, [], childOptions)
if (!uninstallOnly) {
await execute(progressTest, [], childOptions)
await execute(presentationTest, [], childOptions)
}
await execute(cleanupTest, [join(output, 'ui', `cleanup-${id}`)], childOptions)
const payloadSource = join(output, 'payload.nsi')
await writeFile(payloadSource, `Unicode true
RequestExecutionLevel user
@@ -81,12 +89,12 @@ SectionEnd
await execute(compiler.path, ['/V2', payloadSource], { ...childOptions, env: { ...childOptions.env, ...compiler.env } })
if (sign) await sign({ path: join(payload, `${productName}.exe`), hash: 'sha256', isNest: false })
const sourceStrings = await readFile(join(appRoot, 'installer', 'strings.nsh'), 'utf8')
const config = createElectronBuilderConfig()
if (sign) {
config.win.forceCodeSigning = true
config.win.signtoolOptions.sign = sign
}
for (const language of ['en_US', 'zh_CN']) {
const config = createElectronBuilderConfig()
if (sign) {
config.win.forceCodeSigning = true
config.win.signtoolOptions.sign = sign
}
const languageOutput = join(output, language)
await mkdir(languageOutput)
const strings = join(languageOutput, 'strings.nsh')
@@ -96,12 +104,16 @@ SectionEnd
const include = join(languageOutput, 'include.nsh')
await writeFile(include, `!define INSTALLER_BUILD_DIR "${join(output, 'ui')}"\n!define INSTALLER_STRINGS_FILE "${strings}"\n!include "${join(appRoot, 'scripts', 'installer.nsh')}"\n`)
await build({ projectDir: appRoot, prepackaged: payload, targets: Platform.WINDOWS.createTarget(['nsis'], Arch.x64), publish: 'never',
config: { ...config, productName, artifactName: 'installer-test.exe', directories: { output: languageOutput },
config: { ...config, productName, extraMetadata: { ...config.extraMetadata, name: packageName },
artifactName: 'installer-test.exe', directories: { output: languageOutput },
nsis: { ...config.nsis, guid, include, installerLanguages: [language] }, beforeBuild: undefined, afterPack: undefined, afterSign: undefined, artifactBuildCompleted: undefined },
})
if (process.argv.includes('--compile-only')) continue
const result = await execute('powershell.exe', ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File',
join(appRoot, 'tests', 'windows-installer-smoke.ps1'), '-Installer', join(languageOutput, 'installer-test.exe'),
'-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput], childOptions)
join(appRoot, 'tests', uninstallOnly ? 'windows-uninstall-smoke.ps1' : 'windows-installer-smoke.ps1'),
'-Installer', join(languageOutput, 'installer-test.exe'),
'-ProductName', productName, '-RegistryKey', guid, '-OutputDirectory', languageOutput,
...uninstallOnly ? ['-Language', languageId, '-PackageName', packageName] : []], childOptions)
process.stdout.write(`${language}\n${result.stdout}`)
}
succeeded = true
@@ -14,5 +14,12 @@ export function directoryInstallSection(source: string): string
*/
export function directoryInstallerExits(source: string): string
/**
* Keep user-data removal in the native helper, which refuses unsafe roots and never follows links.
* @param source - Pinned upstream uninstaller source.
* @returns Uninstaller with long-path application removal and no upstream RMDir data removal.
*/
export function directoryUninstaller(source: string): string
/** Install the build-only adapter while retaining signed uninstaller generation. */
export function installWindowsDirectoryInstaller(): void
@@ -78,9 +78,21 @@ export function installWindowsDirectoryInstaller() {
adapted = replaceOnce(adapted, `!include "${helper}"`, `!include "${path}"`)
}
const uninstaller = join(directory, 'uninstaller.nsh')
await writeFile(uninstaller, replaceOnce(await readFile(join(templates, 'uninstaller.nsh'), 'utf8'),
'RMDir /r $INSTDIR', 'RMDir /r "\\\\?\\$INSTDIR"'))
await writeFile(uninstaller, directoryUninstaller(await readFile(join(templates, 'uninstaller.nsh'), 'utf8')))
adapted = replaceOnce(adapted, '!include "uninstaller.nsh"', `!include "${uninstaller}"`)
return `!define DSH_SEVENZIP_PATH "${tool}"\n!define DSH_SEVENZIP_LICENSE_DIR "${dirname(dirname(sourceTool))}"\n${await compute.call(this, adapted, ...args)}`
return `!define DSH_UPDATER_CACHE_NAME "${this.packager.appInfo.updaterCacheDirName}"\n!define DSH_SEVENZIP_PATH "${tool}"\n!define DSH_SEVENZIP_LICENSE_DIR "${dirname(dirname(sourceTool))}"\n${await compute.call(this, adapted, ...args)}`
}
}
/**
* Keep user-data removal in the native helper, which refuses unsafe roots and never follows links.
* @param {string} source - Pinned upstream uninstaller source.
* @returns {string} Uninstaller with long-path application removal and no upstream RMDir data removal.
*/
export function directoryUninstaller(source) {
const normalized = source.replaceAll('\r\n', '\n')
const start = normalized.indexOf(' Var /GLOBAL isDeleteAppData\n')
const end = normalized.indexOf(' DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"', start)
if (start < 0 || end < 0) throw new Error('NSIS uninstaller data removal template changed')
return replaceOnce(normalized.slice(0, start) + normalized.slice(end), 'RMDir /r $INSTDIR', 'RMDir /r "\\\\?\\$INSTDIR"')
}
@@ -0,0 +1,3 @@
{
"cases": ["interactive", "silent", "upgrade", "replaced", "home-inside"]
}
@@ -34,6 +34,7 @@ class RosterOnlyJobRegistry extends JobRegistry {
readAt(): never { throw new Error('unsupported') }
kill(): never { throw new Error('unsupported') }
wait(): never { throw new Error('unsupported') }
remove(): never { throw new Error('unsupported') }
attachController(): () => void { return () => {} }
}
@@ -3,12 +3,26 @@ import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import { dirname, join } from 'node:path'
import { expect, it } from 'vitest'
import { directoryInstallerExits, directoryInstallSection } from '../scripts/windows-directory-installer.mjs'
import { directoryInstallerExits, directoryInstallSection, directoryUninstaller } from '../scripts/windows-directory-installer.mjs'
const require = createRequire(import.meta.url)
const section = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')),
'templates/nsis/installSection.nsh'), 'utf8')
it('keeps data cleanup out of the upstream template while retaining application removal and registration cleanup', () => {
const source = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')), 'templates/nsis/uninstaller.nsh'), 'utf8')
const adapted = directoryUninstaller(source)
expect(adapted).not.toContain('--delete-app-data')
expect(adapted).not.toContain('RMDir /r "$APPDATA')
expect(adapted).toContain('!insertmacro customUnInstall')
expect(adapted).toContain('DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"')
expect(adapted).toContain('DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY_2}"')
expect(directoryUninstaller(source.replaceAll('\n', '\r\n'))).toBe(adapted)
expect(adapted).toContain('RMDir /r "\\\\?\\$INSTDIR"')
expect(() => directoryUninstaller(source.replace(' Var /GLOBAL isDeleteAppData\n', ''))).toThrow('template changed')
expect(() => directoryUninstaller(source.replace(' DeleteRegKey SHELL_CONTEXT "${UNINSTALL_REGISTRY_KEY}"', ''))).toThrow('template changed')
})
it.each(['allowOnlyOneInstallerInstance.nsh', 'installUtil.nsh'])('cleans staged files before %s exits', (helper) => {
const source = readFileSync(join(dirname(require.resolve('app-builder-lib/package.json')), 'templates/nsis/include', helper), 'utf8')
const exits = source.match(/^\s*Quit\s*$/gm) ?? []
+4 -2
View File
@@ -197,8 +197,10 @@ public static class InstallerCapture {
RedrawWindow(window, IntPtr.Zero, IntPtr.Zero, 0x181);
}
public static string Save(IntPtr window, string path) {
Reveal(window);
public static string Save(IntPtr window, string path) { return SaveNative(window, path, true); }
public static string SaveNative(IntPtr window, string path, bool waitForInstaller) {
if (waitForInstaller) Reveal(window);
Rect rect;
if (!GetWindowRect(window, out rect)) throw new InvalidOperationException("Could not read preview bounds");
int width = rect.Right - rect.Left, height = rect.Bottom - rect.Top;
@@ -0,0 +1,91 @@
// Native cleanup regressions operate only in the caller's private test directory.
#define WIN32_LEAN_AND_MEAN
#define NOMINMAX
#define UNICODE
#include <windows.h>
#include <cassert>
#include "../installer/uninstall-data.h"
static void File(const std::wstring& path, DWORD attributes = FILE_ATTRIBUTE_NORMAL) {
HANDLE file = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_NEW, attributes, nullptr);
assert(file != INVALID_HANDLE_VALUE);
DWORD count;
assert(WriteFile(file, "retained", 8, &count, nullptr));
CloseHandle(file);
}
static bool Exists(const std::wstring& path) { return GetFileAttributesW(path.c_str()) != INVALID_FILE_ATTRIBUTES; }
int wmain(int argc, wchar_t** argv) {
assert(argc == 2);
const std::wstring root = argv[1];
const std::wstring installation = root + L"\\application";
const std::wstring data = root + L"\\data";
const std::wstring cache = data + L"\\Local Storage\\leveldb";
assert(CreateDirectoryW(root.c_str(), nullptr));
assert(CreateDirectoryW(installation.c_str(), nullptr));
assert(CreateDirectoryW(data.c_str(), nullptr));
assert(CreateDirectoryW((data + L"\\Local Storage").c_str(), nullptr));
assert(CreateDirectoryW(cache.c_str(), nullptr));
File(data + L"\\Local State");
File(cache + L"\\CURRENT");
File(cache + L"\\LOCK", FILE_ATTRIBUTE_READONLY);
File(cache + L"\\MANIFEST");
// Refused roots: the test root contains the installation; shell roots and malformed paths are rejected.
assert(uninstall_data::Remove(root.c_str(), installation.c_str(), nullptr) == ERROR_ACCESS_DENIED);
assert(uninstall_data::Remove(L"C:\\", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
assert(uninstall_data::Remove(L"relative", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
assert(uninstall_data::Remove(L"C:\\bad*", installation.c_str(), nullptr) == ERROR_INVALID_NAME);
PWSTR profile = nullptr;
assert(SUCCEEDED(SHGetKnownFolderPath(FOLDERID_Profile, 0, nullptr, &profile)));
assert(uninstall_data::Remove(profile, installation.c_str(), nullptr) == ERROR_ACCESS_DENIED);
CoTaskMemFree(profile);
// A protected root overlapping the target in either direction, or an unusable one, stops removal.
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), data.c_str()) == ERROR_ACCESS_DENIED);
assert(uninstall_data::Remove(data.c_str(), installation.c_str(), cache.c_str()) == ERROR_ACCESS_DENIED);
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), L"relative\\home") == ERROR_INVALID_NAME);
assert(Exists(cache + L"\\CURRENT"));
// A pinned directory cannot be renamed or replaced while the helper holds it.
{
uninstall_data::Handles pinned;
assert(uninstall_data::Pin(cache, pinned) == ERROR_SUCCESS);
assert(!MoveFileExW(cache.c_str(), (data + L"\\renamed").c_str(), 0));
assert(GetLastError() == ERROR_SHARING_VIOLATION || GetLastError() == ERROR_ACCESS_DENIED);
}
assert(Exists(cache));
// A locked file returns its error but every sibling, including a read-only one, is still removed.
HANDLE held = CreateFileW((cache + L"\\CURRENT").c_str(), GENERIC_READ, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
assert(held != INVALID_HANDLE_VALUE);
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SHARING_VIOLATION);
assert(Exists(cache + L"\\CURRENT"));
assert(!Exists(cache + L"\\LOCK"));
assert(!Exists(cache + L"\\MANIFEST"));
CloseHandle(held);
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SUCCESS);
assert(Exists(data + L"\\Local State"));
assert(uninstall_data::Remove(cache.c_str(), installation.c_str(), nullptr) == ERROR_SUCCESS);
// Empty ordinary parents below the stop directory are removed; the stop directory and populated parents stay.
const std::wstring scope = root + L"\\@scope";
const std::wstring package = scope + L"\\package";
assert(CreateDirectoryW(scope.c_str(), nullptr));
assert(CreateDirectoryW(package.c_str(), nullptr));
assert(uninstall_data::RemoveEmptyParents(package.c_str(), root.c_str()) == ERROR_DIR_NOT_EMPTY);
assert(Exists(scope));
assert(RemoveDirectoryW(package.c_str()));
assert(uninstall_data::RemoveEmptyParents(root.c_str(), root.c_str()) == ERROR_ACCESS_DENIED);
assert(uninstall_data::RemoveEmptyParents(package.c_str(), root.c_str()) == ERROR_SUCCESS);
assert(!Exists(scope));
assert(Exists(root));
// A protected root elsewhere does not block removal.
assert(uninstall_data::Remove(data.c_str(), installation.c_str(), (root + L"\\elsewhere").c_str()) == ERROR_SUCCESS);
assert(!Exists(data));
assert(RemoveDirectoryW(installation.c_str()));
assert(RemoveDirectoryW(root.c_str()));
}
@@ -0,0 +1,102 @@
<# Production uninstaller checks with isolated application identity and seeded data. #>
[CmdletBinding()]
param([Parameter(Mandatory)][string]$Installer, [Parameter(Mandatory)][string]$ProductName,
[Parameter(Mandatory)][string]$RegistryKey, [Parameter(Mandatory)][string]$OutputDirectory,
[Parameter(Mandatory)][string]$Language, [Parameter(Mandatory)][string]$PackageName)
$ErrorActionPreference = 'Stop'
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
. (Join-Path $PSScriptRoot 'windows-installer-ui.ps1')
[InstallerCapture]::Initialize()
$expected = Get-Content (Join-Path $PSScriptRoot 'expected/windows-uninstall.json') -Raw | ConvertFrom-Json
$roaming = [Environment]::GetFolderPath('ApplicationData')
$local = [Environment]::GetFolderPath('LocalApplicationData')
$installPath = Join-Path $OutputDirectory 'Uninstall App'
$homePath = Join-Path $OutputDirectory 'Harness Home'
$externalPath = Join-Path $OutputDirectory 'External Project'
$productDataPath = Join-Path $roaming $ProductName
# Electron nests user data under the package scope; electron-builder strips the separator from the updater cache name.
$scopePath = Join-Path $roaming $PackageName.Split('/')[0]
$userDataPath = Join-Path $roaming ($PackageName -replace '/', '\')
$cachePath = Join-Path $local (($PackageName -replace '[/\\?<>:*|"]', '').ToLowerInvariant() + '-updater')
$uninstaller = Join-Path $OutputDirectory 'uninstall-copy.exe'
$process = $null
function Wait-Text([string]$Text) {
$timer = [Diagnostics.Stopwatch]::StartNew()
do {
$control = [InstallerCapture]::FindText($process.Id, $Text)
if ($control -ne [IntPtr]::Zero) { return $control }
if ($process.HasExited) { throw "Uninstaller exited: $($process.ExitCode)" }
Start-Sleep -Milliseconds 25
} while ($timer.Elapsed.TotalSeconds -lt 20)
throw "Missing '$Text': $([InstallerCapture]::VisibleText($process.Id))"
}
function Wait-Exit {
if (-not $process.WaitForExit(30000)) { $process.Kill(); $process.WaitForExit(); throw 'Uninstaller timed out' }
if ($process.ExitCode -ne 0) { throw "Uninstall failed: $($process.ExitCode)" }
$process.Dispose()
$script:process = $null
# The NSIS uninstaller hands its section to a temporary copy; the launched process exits before removal completes.
$timer = [Diagnostics.Stopwatch]::StartNew()
while (Test-Path -LiteralPath $installPath) {
if ($timer.Elapsed.TotalSeconds -gt 20) { throw 'Application directory remained' }
Start-Sleep -Milliseconds 100
}
}
try {
foreach ($mode in $expected.cases) {
$setup = Start-Process -FilePath $Installer -ArgumentList ('/S /D=' + $installPath) -PassThru -WindowStyle Hidden
try {
if (-not $setup.WaitForExit(30000)) { $setup.Kill(); $setup.WaitForExit(); throw 'Setup timed out' }
if ($setup.ExitCode -ne 0) { throw "Setup failed: $($setup.ExitCode)" }
} finally { $setup.Dispose() }
$entry = Get-ItemProperty ('HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\' + $RegistryKey)
if ($entry.InstallLocation.TrimEnd('\') -ne $installPath) { throw 'Uninstall entry lacks InstallLocation' }
foreach ($path in @($homePath, $productDataPath, $userDataPath, $cachePath, $externalPath)) { New-Item -ItemType Directory -Force -Path $path | Out-Null }
[IO.File]::WriteAllText((Join-Path $externalPath 'keep.txt'), 'outside data root')
if (-not (Test-Path -LiteralPath (Join-Path $userDataPath 'linked-project'))) {
New-Item -ItemType Junction -Path (Join-Path $userDataPath 'linked-project') -Target $externalPath | Out-Null
}
[IO.File]::WriteAllText((Join-Path $homePath 'session.txt'), 'keep session')
[IO.File]::WriteAllText((Join-Path $productDataPath 'state.txt'), 'product data')
[IO.File]::WriteAllText((Join-Path $userDataPath 'Local State'), 'chromium profile')
[IO.File]::WriteAllText((Join-Path $cachePath 'download.txt'), 'update')
Copy-Item -LiteralPath (Join-Path $installPath ('Uninstall ' + $ProductName + '.exe')) -Destination $uninstaller -Force
$arguments = '_?=' + $installPath
if ($mode -in @('silent', 'home-inside')) { $arguments = '/S ' + $arguments }
# electron-builder's installUtil.nsh runs an older uninstaller with exactly these flags when replacing it.
if ($mode -eq 'upgrade') { $arguments = '/S /KEEP_APP_DATA --updated /currentuser ' + $arguments }
if ($mode -eq 'replaced') { $arguments = '/S /KEEP_APP_DATA /currentuser ' + $arguments }
if ($mode -eq 'home-inside') { $env:DSH_HOME = Join-Path $userDataPath 'home' } else { Remove-Item Env:DSH_HOME -ErrorAction SilentlyContinue }
$process = Start-Process -FilePath $uninstaller -ArgumentList $arguments -PassThru -WindowStyle ($(if ($mode -eq 'interactive') { 'Normal' } else { 'Hidden' }))
if ($mode -eq 'interactive') {
$welcomeText = if ($Language -eq 'ENGLISH') { 'Welcome' } else { -join [char[]]@(0x6B22, 0x8FCE) }
$welcome = Wait-Text $welcomeText
$window = [InstallerCapture]::TopLevel($welcome)
[void][InstallerCapture]::SaveNative($window, (Join-Path $OutputDirectory 'uninstall-welcome.png'), $false)
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1))
$finishedText = if ($Language -eq 'ENGLISH') { 'has been uninstalled' } else { -join [char[]]@(0x5DF2, 0x4ECE, 0x4F60) }
[void](Wait-Text $finishedText)
[InstallerCapture]::Click([InstallerCapture]::GetDlgItem($window, 1))
}
Wait-Exit
$retained = $mode -in @('upgrade', 'replaced')
# A DSH_HOME inside the Electron directory protects that directory (and its scope) but not the other roots.
$userDataRetained = $retained -or $mode -eq 'home-inside'
if ([IO.File]::ReadAllText((Join-Path $externalPath 'keep.txt')) -ne 'outside data root') { throw 'Cleanup followed a junction into a project' }
if (-not (Test-Path (Join-Path $homePath 'session.txt'))) { throw "Harness home retention failed: $mode" }
if ((Test-Path (Join-Path $productDataPath 'state.txt')) -ne $retained) { throw "Product data retention failed: $mode" }
if ((Test-Path (Join-Path $userDataPath 'Local State')) -ne $userDataRetained) { throw "Electron user data retention failed: $mode" }
if ((Test-Path -LiteralPath $scopePath) -ne $userDataRetained) { throw "Package scope directory retention failed: $mode" }
if ((Test-Path (Join-Path $cachePath 'download.txt')) -ne $retained) { throw "Update cache retention failed: $mode" }
Write-Output "passed: $mode"
}
} finally {
Remove-Item Env:DSH_HOME -ErrorAction SilentlyContinue
if ($process) { if (-not $process.HasExited) { $process.Kill(); $process.WaitForExit() }; $process.Dispose() }
# Only unique test identities below known application-data parents are removed.
foreach ($pair in @(@($productDataPath, $roaming), @($scopePath, $roaming), @($cachePath, $local))) {
$target = [IO.Path]::GetFullPath($pair[0])
if ([IO.Path]::GetDirectoryName($target) -ne $pair[1] -or -not ([IO.Path]::GetFileName($target).StartsWith('Harness Installer Test ') -or [IO.Path]::GetFileName($target).StartsWith('@harness-installer-test'))) { throw 'Unexpected fixture cleanup path' }
if (Test-Path -LiteralPath $target) { Remove-Item -LiteralPath $target -Recurse -Force }
}
}
+16 -3
View File
@@ -80,7 +80,7 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await scaffold?.close()
})
it('shows a running background job in the header without a refresh, then flips it to cancelled on settlement', async () => {
it('shows a running background job in the header without a refresh, then kills it from the two-press stop control', async () => {
let phase = 'running'
onTestFailed(() => saveFailureShot(page, `web-e2e-background-job-${phase}`))
// Polling for zero would pass at t=0 before delivery and prove nothing.
@@ -105,16 +105,29 @@ describe.skipIf(MODE === 'record')('web e2e: background job list', () => {
await row.waitFor({ timeout: 10_000 })
await expect.poll(() => row.textContent()).toContain(COMMAND)
const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
const running = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd, { runningJobs: 'keep' })
await compareOrRefreshGolden(RUNNING_EXPECTED, running, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
phase = 'settled'
expect(scaffold.ctx.jobs.kill(jobId, agent.id, 'web e2e cancellation')).toBe('requested')
// The whole human path: arm, confirm, job.kill, registry kill, jobs
// frames flipping the row — no registry call from the test.
const stop = page.locator('[data-kill-state]')
await stop.waitFor({ timeout: 10_000 })
await stop.click()
await expect.poll(() => stop.getAttribute('data-kill-state')).toBe('armed')
await stop.click()
// Exact: the running trigger's name ('1 background job running') contains this label.
const idle = page.getByRole('button', { name: '1 background job', exact: true })
await idle.waitFor({ timeout: 20_000 })
// The unclaimed report's reason lands in the settled row's detail.
await expect.poll(
() => page.getByRole('list', { name: 'Background jobs' }).textContent(),
{ timeout: 15_000 },
).toContain('cancelled by the user')
expect(scaffold.ctx.jobs.get(jobId, agent.id).status).toBe('killed')
const settled = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
await compareOrRefreshGolden(SETTLED_EXPECTED, settled, MODE)
+12 -11
View File
@@ -761,7 +761,9 @@ else process.exit(1);
await openFile('budget.xlsx')
const excel = preview.locator('[data-excel-preview]')
await excel.getByText('季度预算', { exact: true }).waitFor({ state: 'visible' })
const sheetTabs = excel.locator('.luckysheet-sheets-item-name')
await sheetTabs.getByText('季度预算', { exact: true }).waitFor({ state: 'visible' })
expect(await sheetTabs.getByText('隐藏页', { exact: true }).isVisible()).toBe(false)
await successShot(page, 'excel-budget')
expect(await preview.locator('[data-pdf-preview]').count()).toBe(0)
expect(await excel.locator('.fortune-toolbar').count()).toBe(0)
@@ -771,7 +773,7 @@ else process.exit(1);
await expect.poll(() => formulaInput.innerText()).toBe('=C3/B3')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('80.0%\n')
await excel.getByText('公式与格式', { exact: true }).click()
await sheetTabs.getByText('公式与格式', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await sheetOverlay.click({ position: { x: 140, y: 30 } })
await expect.poll(() => formulaInput.innerText()).toBe('46281')
@@ -787,23 +789,23 @@ else process.exit(1);
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('42\n')
await successShot(page, 'excel-cached-formula')
await openFile('meeting.xlsx')
await excel.getByText('会议信息', { exact: true }).waitFor({ state: 'visible' })
await sheetTabs.getByText('会议信息', { exact: true }).waitFor({ state: 'visible' })
expect(await excel.getByText('Read-only preview', { exact: false }).count()).toBe(0)
expect(await excel.getByText('Some formulas have no saved result', { exact: false }).count()).toBe(0)
const formulaWarning = excel.locator('[data-excel-formula-warning]')
await formulaWarning.hover()
await page.getByText('This workbook contains formulas. Displayed results may be missing or inaccurate.', { exact: true }).waitFor()
await excel.getByText('统计看板', { exact: true }).click()
await sheetTabs.getByText('统计看板', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await successShot(page, 'excel-meeting')
await openFile('budget.xls')
await excel.getByText('预算', { exact: true }).waitFor({ state: 'visible' })
await sheetTabs.getByText('预算', { exact: true }).waitFor({ state: 'visible' })
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
await expect.poll(() => excel.locator('.fortune-fx-input').innerText()).toBe('旧版预算')
await page.keyboard.press('ControlOrMeta+C')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('旧版预算')
await excel.getByText('明细', { exact: true }).click()
await sheetTabs.getByText('明细', { exact: true }).click()
await expect.poll(() => excel.locator('.fortune-name-box').innerText()).toBe('A1')
await successShot(page, 'excel-legacy')
await openFile('literal-html.xlsx')
@@ -812,7 +814,7 @@ else process.exit(1);
['Text', excelHtmlText, excelHtmlText],
['Cached text', '="cached"', excelHtmlText],
] as const) {
await excel.getByText(sheet, { exact: true }).click()
await sheetTabs.getByText(sheet, { exact: true }).click()
await expect.poll(() => formulaInput.textContent()).toBe(formula)
expect(await formulaInput.locator('img').count()).toBe(0)
await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } })
@@ -899,7 +901,7 @@ else process.exit(1);
const emptyOpen = unsupported.locator('[data-textpreview-unsupported] [data-open-path-unpreviewable]')
await emptyOpen.waitFor({ timeout: 15_000 })
const prominent = unsupported.locator('[data-open-target="file"][data-size="large"]')
expect((await prominent.boundingBox())?.height).toBe(40)
expect((await prominent.boundingBox())?.height).toBe(36)
expect((await unsupported.locator('[data-open-path]').boundingBox())?.height).toBe(24)
await successShot(page, 'unsupported')
sections.push([
@@ -975,10 +977,9 @@ describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
await Promise.all([
writeFile(join(cwd, 'renamed.docx'), 'This is plain text renamed to docx.'),
writeFile(join(cwd, 'chinese.docx'), realOfficeBytes('docx', 'DSH Missing Preview Font')),
writeFile(join(cwd, 'chinese.xlsx'), realOfficeBytes('xlsx')),
writeFile(join(cwd, 'chinese.pptx'), realOfficeBytes('pptx')),
...(['doc', 'xls', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))),
...['doc', 'xls', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')),
...(['doc', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))),
...['doc', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')),
])
const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert')
try {
+1 -1
View File
@@ -110,7 +110,7 @@ describe.skipIf(MODE === 'record')('web e2e: live job stream', () => {
await expand.click()
await page.getByText('streamed-marker-line').waitFor({ timeout: 15_000 })
const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
const streaming = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd, { runningJobs: 'keep' })
await compareOrRefreshGolden(STREAMING_EXPECTED, streaming, MODE)
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
+12 -2
View File
@@ -1538,13 +1538,18 @@ function normalizeAria(snapshot: string, workspaceCwd: string, age: boolean): st
/**
* Capture the region's aria snapshot at a settled milestone: poll until two
* consecutive normalized captures are equal — a single-shot capture races the
* last React commits.
* last React commits. A foreground shell command is a job while it runs, and
* its removal reaches the browser one coalesced roster frame after the tool
* result, so by default the capture first waits for the session header's
* running-job control to leave; a scenario whose milestone is a running job
* keeps it with `runningJobs: 'keep'`.
* @param page - the page under test.
* @param selector - the region locator selector.
* @param workspaceCwd - normalization input.
* @param options - `normalizeAge` collapses relative-time buckets to `{{age}}`
* for a region whose rows are dated from live wall-clock state;
* `replacements` tokenizes scenario-owned values before generic normalization.
* `replacements` tokenizes scenario-owned values before generic normalization;
* `runningJobs` is `'settle'` (default: wait for no running-job control) or `'keep'`.
* @returns the stable normalized snapshot.
*/
export async function captureStableAria(
@@ -1554,8 +1559,13 @@ export async function captureStableAria(
options: {
normalizeAge?: boolean
replacements?: readonly (readonly [value: string, token: string])[]
runningJobs?: 'settle' | 'keep'
} = {},
): Promise<string> {
if ((options.runningJobs ?? 'settle') === 'settle') {
await page.getByRole('button', { name: /background jobs? running/ })
.waitFor({ state: 'detached', timeout: 10_000 })
}
const region = page.locator(selector).first()
const age = options.normalizeAge === true
const normalize = (snapshot: string): string => {
@@ -1,4 +1,7 @@
- list "Background jobs":
- listitem:
- button "Finished 1" [expanded]
- button "Clear"
- listitem:
- button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: "bash printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done signal: SIGTERM; web e2e cancellation {{duration}}"
- text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done
@@ -1,6 +1,7 @@
- list "Background jobs":
- listitem:
- button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done {{duration}} bash running
- button "Hide live output of printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done" [expanded]: printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done bash {{duration}}
- button "Stop task printf 'streamed-%s\\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done"
- text: $ printf 'streamed-%s\n' marker-line; for _ in $(seq 1 3000); do [ -e .live-job-stream.release ] && break; sleep 0.2; done
- button "Copy"
- text: streamed-marker-line
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/config-catalog.md
config-catalog.md: 719d81ea75cafd2cfa99990cb85a10efb3071608
config-catalog.zh.md: dd5890e160abdb2fb93825e8def2aa32edb39500
config-catalog.md: 32faec64e3ece1053430219c6be57a7044308f5a
config-catalog.zh.md: 08a7b368e0332b917de1f627a22415ede4d18f36
+41 -7
View File
@@ -202,7 +202,7 @@ export interface Config {
}
```
Source: [`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts)
Source: [`packages/api/job-controller/src/index.ts:35`](../packages/api/job-controller/src/index.ts)
<a id="deepseek-aidsh-api-session-controller"></a>
@@ -3156,12 +3156,25 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv`
```ts config-catalog
/** Configuration for the bash tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
/**
* Expose `run_in_background` while a job registry is composed (default
* true); disabled calls are also rejected. Without a registry the tool is
* foreground-only regardless.
*/
enableRunInBackground?: boolean
/**
* Keep a foreground command that reaches its timeout running as a
* background job instead of killing it (default true). Applies only while
* background execution is available: with `enableRunInBackground` false or
* no job registry, the executor's deadline kills the command. A foreground
* command the registry refuses at its start (admission or a missing
* controller) also runs under the deadline kill.
*/
promoteOnTimeout?: boolean
}
```
Source: [`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts)
Source: [`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts)
<a id="deepseek-aidsh-tool-bash-persistent"></a>
@@ -3290,7 +3303,7 @@ export interface Config {
export type CompletionDelivery = 'quiet' | 'wakeup'
```
Source: [`packages/jobs/tool-jobs/src/index.ts:52`](../packages/jobs/tool-jobs/src/index.ts)
Source: [`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
<a id="deepseek-aidsh-tool-lsp"></a>
@@ -3337,12 +3350,25 @@ Requires: `tools` · `shell` · `systemPrompt` · `shellEnv`
```ts config-catalog
/** Configuration for the pwsh tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
/**
* Expose `run_in_background` while a job registry is composed (default
* true); disabled calls are also rejected. Without a registry the tool is
* foreground-only regardless.
*/
enableRunInBackground?: boolean
/**
* Keep a foreground command that reaches its timeout running as a
* background job instead of killing it (default true). Applies only while
* background execution is available: with `enableRunInBackground` false or
* no job registry, the executor's deadline kills the command. A foreground
* command the registry refuses at its start (admission or a missing
* controller) also runs under the deadline kill.
*/
promoteOnTimeout?: boolean
}
```
Source: [`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
Source: [`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts)
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
@@ -3592,10 +3618,18 @@ export interface Config {
toolName?: string
/** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
maxResultChars?: number
/**
* Expose `run_in_background` (default true); disabled calls are also
* rejected. A background run needs a live `ctx.jobs` registry with a
* controller serving the caller (`dsh-jobs-local` plus `dsh-tool-jobs` in
* the shipped composition); without one the call fails with the missing
* piece named.
*/
enableRunInBackground?: boolean
}
```
Source: [`packages/workflow/tool-workflow/src/index.ts:32`](../packages/workflow/tool-workflow/src/index.ts)
Source: [`packages/workflow/tool-workflow/src/index.ts:44`](../packages/workflow/tool-workflow/src/index.ts)
<a id="deepseek-aidsh-tool-workspace-dependencies"></a>
+40 -6
View File
@@ -204,7 +204,7 @@ export interface Config {
}
```
来源:[`packages/api/job-controller/src/index.ts:33`](../packages/api/job-controller/src/index.ts)
来源:[`packages/api/job-controller/src/index.ts:35`](../packages/api/job-controller/src/index.ts)
<a id="deepseek-aidsh-api-session-controller"></a>
@@ -3160,12 +3160,25 @@ export type TokenMeterConfig = Record<string, never>
```ts config-catalog
/** Configuration for the bash tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
/**
* Expose `run_in_background` while a job registry is composed (default
* true); disabled calls are also rejected. Without a registry the tool is
* foreground-only regardless.
*/
enableRunInBackground?: boolean
/**
* Keep a foreground command that reaches its timeout running as a
* background job instead of killing it (default true). Applies only while
* background execution is available: with `enableRunInBackground` false or
* no job registry, the executor's deadline kills the command. A foreground
* command the registry refuses at its start (admission or a missing
* controller) also runs under the deadline kill.
*/
promoteOnTimeout?: boolean
}
```
来源:[`packages/shell/tool-bash/src/index.ts:33`](../packages/shell/tool-bash/src/index.ts)
来源:[`packages/shell/tool-bash/src/index.ts:37`](../packages/shell/tool-bash/src/index.ts)
<a id="deepseek-aidsh-tool-bash-persistent"></a>
@@ -3294,7 +3307,7 @@ export interface Config {
export type CompletionDelivery = 'quiet' | 'wakeup'
```
来源:[`packages/jobs/tool-jobs/src/index.ts:37`](../packages/jobs/tool-jobs/src/index.ts)
来源:[`packages/jobs/tool-jobs/src/index.ts:41`](../packages/jobs/tool-jobs/src/index.ts)
<a id="deepseek-aidsh-tool-lsp"></a>
@@ -3341,12 +3354,25 @@ export interface Config {
```ts config-catalog
/** Configuration for the pwsh tool. */
export interface Config {
/** Expose `run_in_background` (default true); disabled calls are also rejected. */
/**
* Expose `run_in_background` while a job registry is composed (default
* true); disabled calls are also rejected. Without a registry the tool is
* foreground-only regardless.
*/
enableRunInBackground?: boolean
/**
* Keep a foreground command that reaches its timeout running as a
* background job instead of killing it (default true). Applies only while
* background execution is available: with `enableRunInBackground` false or
* no job registry, the executor's deadline kills the command. A foreground
* command the registry refuses at its start (admission or a missing
* controller) also runs under the deadline kill.
*/
promoteOnTimeout?: boolean
}
```
来源:[`packages/shell/tool-pwsh/src/index.ts:52`](../packages/shell/tool-pwsh/src/index.ts)
来源:[`packages/shell/tool-pwsh/src/index.ts:54`](../packages/shell/tool-pwsh/src/index.ts)
<a id="deepseek-aidsh-tool-pwsh-persistent"></a>
@@ -3596,6 +3622,14 @@ export interface Config {
toolName?: string
/** Rendered-result ceiling, in characters: a longer JSON value is truncated with a notice (default 50000). */
maxResultChars?: number
/**
* Expose `run_in_background` (default true); disabled calls are also
* rejected. A background run needs a live `ctx.jobs` registry with a
* controller serving the caller (`dsh-jobs-local` plus `dsh-tool-jobs` in
* the shipped composition); without one the call fails with the missing
* piece named.
*/
enableRunInBackground?: boolean
}
```
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/event-producer-consumer.md
event-producer-consumer.md: 741593e3f869994c260b580d9dddb18bdbf5193f
event-producer-consumer.zh.md: 323d37214254a54a9ca13d1918523cb1c92fd32a
event-producer-consumer.md: 2281f3bbd07597f945c41ea04ff96dca60b8c2fd
event-producer-consumer.zh.md: a615881f831503c2f8abcf23336f87f05640a87a
+2 -2
View File
@@ -79,8 +79,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## Non-harness or undeclared event strings seen in package source
+2 -2
View File
@@ -81,8 +81,8 @@
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:68`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow), [`workflow`](../packages/workflow/workflow) |
| `workflow/end` | `emit` | [`packages/workflow/workflow/src/index.ts:89`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | - |
| `workflow/log` | `emit` | [`packages/workflow/workflow/src/index.ts:58`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/phase` | `emit` | [`packages/workflow/workflow/src/index.ts:51`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
| `workflow/start` | `emit` | [`packages/workflow/workflow/src/index.ts:43`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) |
## Non-harness or undeclared event strings seen in package source
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/module-graph.md
module-graph.md: ebf7f24f04b3a82c1d8851311ff55a7dab077e88
module-graph.zh.md: 2e70e7c375e1e45e1b3448528b4d16e96d18a6d0
module-graph.md: 2e22c198c29bfba5eaa1a548af3bb7a9f190d0c8
module-graph.zh.md: ce5c445c01b197c260d41af1d991b10fe799873a
+2 -1
View File
@@ -968,6 +968,7 @@ flowchart TD
pkg_llm_replay --> pkg_session
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_jobs
pkg_tool_workflow --> pkg_llm
pkg_tool_workflow --> pkg_session
pkg_tool_workflow --> pkg_system_prompt
@@ -1586,7 +1587,7 @@ flowchart TD
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
+2 -1
View File
@@ -970,6 +970,7 @@ flowchart TD
pkg_llm_replay --> pkg_session
pkg_tool_workflow --> pkg_agent
pkg_tool_workflow --> pkg_invariants
pkg_tool_workflow --> pkg_jobs
pkg_tool_workflow --> pkg_llm
pkg_tool_workflow --> pkg_session
pkg_tool_workflow --> pkg_system_prompt
@@ -1588,7 +1589,7 @@ flowchart TD
| [`ssh`](../packages/ssh/ssh) | `ssh` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`fs-sandbox`](../packages/fs/fs-sandbox), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-local`](../packages/sandbox/sandbox-local), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session-projection`](../packages/session/session-projection), [`subprocess`](../packages/subprocess/subprocess), [`subprocess-local`](../packages/subprocess/subprocess-local) |
| [`tool-terminal`](../packages/terminal/tool-terminal) | `terminal` | [`agent`](../packages/core/agent), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`output-retention`](../packages/util/output-retention), [`system-prompt`](../packages/core/system-prompt), [`terminal`](../packages/terminal/terminal), [`tools`](../packages/core/tools) |
| [`llm-replay`](../packages/test-support/llm-replay) | `test-support` | [`compaction`](../packages/compaction/compaction), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`tool-workflow`](../packages/workflow/tool-workflow) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/runtime-diagnostics/invariants), [`jobs`](../packages/jobs/jobs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) |
| [`plugin-package-inventory-deepseek`](../packages/llm/plugin-package-inventory-deepseek) | `llm` | [`agent`](../packages/core/agent), [`agent-preset-registry`](../packages/preset/agent-preset-registry), [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`session`](../packages/core/session) |
| [`token-meter`](../packages/llm/token-meter) | `llm` | [`compaction`](../packages/compaction/compaction), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection) |
| [`session-query`](../packages/session-query/session-query) | `session-query` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-title`](../packages/session/session-title), [`tool-todo`](../packages/todo/tool-todo) |
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/jobs.md
jobs.md: d54b1ecdd8957c162268c8abbc1df6d764695ac7
jobs.zh.md: 3b0db7c654d02ace451e73203923d816e9262230
jobs.md: 0d235c6101ff20c9ef530bc08efc98000ce05fd0
jobs.zh.md: fbb650bbc10e4c35451b193cdf7317b28980752c
+33 -1
View File
@@ -294,6 +294,14 @@ type JobEvent =
readonly type: 'settled'
readonly job: JobView
readonly cause: JobSettleCause
/**
* Whether this settlement released a live {@link JobRegistry.wait}. That
* waiter's caller receives the terminal projection as its own result, so
* a completion reporter treats an awaited settlement as already delivered
* and reports only the unawaited ones. A wait that timed out or was
* aborted before the settlement does not count.
*/
readonly awaited: boolean
}
| {
readonly type: 'output'
@@ -358,6 +366,19 @@ Host service backing the generated `ctx.remote.job` namespace.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable<JobFollowFrame>
/**
* Kill one background job on a human's behalf. The request's session is
* the fenced read's caller, so the job must be one that session can see:
* the registry's owner fence is the only access rule, and a child session's
* own jobs are killable from its list like any other. The kill records
* `cancelled by the user` as its reason; it is not one the model requested,
* so the owning agent still receives the completion notice, and a shell
* tool waiting on that job reads the reason in its own result.
* @param request - Session whose job list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('kill') kill(request: JobKillRequest): JobKillValue
```
Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts)
@@ -372,7 +393,8 @@ Implementations must honor these semantics:
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left.
- Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter and reports whether it released one (`awaited`), so a completion reporter can skip settlements a waiting caller already collected.
- A settled record stays listed until its owner's disposal, service disposal, or an explicit remove by a caller that collected the terminal state itself and never handed the id out.
- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other.
- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.
@@ -451,6 +473,16 @@ abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'al
*/
abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise<JobView>
/**
* Drop one settled job's record from the visible set and announce
* `removed`. For a caller that collected the terminal state through its own
* {@link wait} and never handed the id to the model, such as a shell tool's
* foreground call. Throws for a job that is still live, unknown, or foreign.
* @param id - settled job to drop.
* @param caller - removing session checked against the owner.
*/
abstract remove(id: JobId, caller?: SessionId): void
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
* owners its registering context's scope covers, and {@link start} refuses an
+33 -1
View File
@@ -294,6 +294,14 @@ type JobEvent =
readonly type: 'settled'
readonly job: JobView
readonly cause: JobSettleCause
/**
* Whether this settlement released a live {@link JobRegistry.wait}. That
* waiter's caller receives the terminal projection as its own result, so
* a completion reporter treats an awaited settlement as already delivered
* and reports only the unawaited ones. A wait that timed out or was
* aborted before the settlement does not count.
*/
readonly awaited: boolean
}
| {
readonly type: 'output'
@@ -358,6 +366,19 @@ Host service backing the generated `ctx.remote.job` namespace.
* @returns anchor, coalesced output frames, and the terminal status.
*/
@Remote({ mode: 'stream' }) follow(request: JobFollowRequest, signal: AbortSignal): AsyncIterable<JobFollowFrame>
/**
* Kill one background job on a human's behalf. The request's session is
* the fenced read's caller, so the job must be one that session can see:
* the registry's owner fence is the only access rule, and a child session's
* own jobs are killable from its list like any other. The kill records
* `cancelled by the user` as its reason; it is not one the model requested,
* so the owning agent still receives the completion notice, and a shell
* tool waiting on that job reads the reason in its own result.
* @param request - Session whose job list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('kill') kill(request: JobKillRequest): JobKillValue
```
Source: [`packages/api/job-controller/src/index.ts`](../../packages/api/job-controller/src/index.ts)
@@ -372,7 +393,8 @@ Implementations must honor these semantics:
- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: 'teardown'`, because a job whose owner is being destroyed has no reader left.
- Owned-job access is fenced by the owner's session id. Ids are predictable, so authorization — not secrecy — is the boundary.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.
- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter and reports whether it released one (`awaited`), so a completion reporter can skip settlements a waiting caller already collected.
- A settled record stays listed until its owner's disposal, service disposal, or an explicit remove by a caller that collected the terminal state itself and never handed the id out.
- start refuses work while no attached job controller serves the spec's owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: 'scope' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition's scope serve exactly the agents composed under it.
- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model's consuming cursor and observers' absolute offsets read the same bytes and never disturb each other.
- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.
@@ -451,6 +473,16 @@ abstract kill(id: JobId, caller?: SessionId, reason?: string): 'requested' | 'al
*/
abstract wait(id: JobId, timeoutMs: number, caller?: SessionId, signal?: AbortSignal): Promise<JobView>
/**
* Drop one settled job's record from the visible set and announce
* `removed`. For a caller that collected the terminal state through its own
* {@link wait} and never handed the id to the model, such as a shell tool's
* foreground call. Throws for a job that is still live, unknown, or foreign.
* @param id - settled job to drop.
* @param caller - removing session checked against the owner.
*/
abstract remove(id: JobId, caller?: SessionId): void
/**
* Attach an effect-scoped controller that can read and stop jobs. It serves the
* owners its registering context's scope covers, and {@link start} refuses an
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/subsystems/shell.md
shell.md: 734b9e7c72948be23d2c6a2c82c6af27c1e2812b
shell.zh.md: bfe455c2e5f680f743188e8accb70008a95ab8f9
shell.md: ad7e7f12ac977d45de3b8ae621a6f848f3ae43b5
shell.zh.md: f0e9bd6ea00629f406638b7031eb456e9bc951c2
+21 -21
View File
@@ -27,6 +27,8 @@ interface ShellExecRequest {
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/** Deadline policy at `timeoutMs` expiry (default `'kill'`). */
onExpiry?: ShellExpiryPolicy | undefined
/**
* Foreground stdout capture budget in bytes. Absent uses the executor's
* default output cap. Trusted in-process consumers use this when they must
@@ -34,7 +36,7 @@ interface ShellExecRequest {
* tool does not expose it as a parameter.
*/
stdoutMaxBytes?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
/** Abort signal — implementations kill the command when it fires, and treat a signal that is already aborted as fired. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
@@ -68,19 +70,21 @@ interface ShellExecRequest {
```ts type-equiv
/**
* A resolved execution spec. {@link ShellExecutor.resolve} fills and caps the
* required fields; {@link ShellExecutor.start} ignores `timeoutMs` because
* background processes have no executor timeout.
* required fields; under `onExpiry: 'none'` the resolved `timeoutMs` arms no
* timer and is only echoed into {@link ShellRunResult.timeoutMs}.
*/
interface ShellExecSpec {
command: string
workdir: string
timeoutMs: number
/** Deadline policy at `timeoutMs` expiry ({@link ShellExecutor.resolve} defaults it to `'kill'`). */
onExpiry: ShellExpiryPolicy
/**
* Resolved foreground stdout capture budget in bytes. `run()` uses it for
* stdout; background jobs and stderr keep the executor's own output cap.
* Resolved stdout capture budget in bytes, applied to every execution's
* stdout; stderr keeps the executor's own output cap.
*/
stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */
/** Abort signal — implementations kill the command when it fires, and treat a signal that is already aborted as fired. */
signal?: AbortSignal | undefined
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
@@ -244,37 +248,33 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
execute resolves with the process handle after preparation. "Foreground" is a property of what the caller awaits, not of the spawn — a caller that awaits ShellExecution.result ran the command in the foreground; one that keeps the handle ran it in the background. A caller that waits only for a while runs the command under `onExpiry: 'none'` and bounds its own wait; the handle stays valid after the caller stops waiting.
Implementations must honor these semantics:
- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.
- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.
- ShellExecution.result rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a descriptive result: first-cause `timedOut`/`aborted`, the spec's `timeoutMs` echoed.
- The handle is published after preparation. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on the read path, while `result()` carries the same failure as its rejection.
- `onExpiry: 'none'` arms no deadline; `'kill'` kills at expiry. Expiry during preparation returns a settled timed-out handle without output.
- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.
- A still-running process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a process survives an executor-only reload.
```ts cordis-catalog
/**
* Apply implementation-owned defaults and caps to a request before execution.
* @param request - the caller's request; omitted fields get this
* implementation's defaults, capped fields are clamped.
* @returns the fully-specified spec to hand to {@link run}/{@link start}.
* @returns the fully-specified spec to hand to {@link execute}.
*/
abstract resolve(request: ShellExecRequest): ShellExecSpec
/**
* Run preparation and the foreground command under the resolved timeout.
* Prepare and spawn the command under its resolved deadline.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the outcome; nonzero exits, timeout kills, and abort kills
* resolve with a descriptive result rather than reject.
* @returns the prepared handle, including its result projection;
* preparation timeout yields an already-settled handle with no output.
* @throws on preparation failure or caller cancellation before process publication.
*/
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
/**
* Prepare a background process asynchronously and publish its live handle.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the live process handle after preparation; cancellation or setup failure rejects.
*/
abstract start(spec: ShellExecSpec): Promise<ShellProcess>
abstract execute(spec: ShellExecSpec): Promise<ShellExecution>
```
Source: [`packages/shell/shell/src/index.ts`](../../packages/shell/shell/src/index.ts)
+21 -21
View File
@@ -27,6 +27,8 @@ interface ShellExecRequest {
workdir?: string | undefined
/** Timeout override in milliseconds (implementations cap it). */
timeoutMs?: number | undefined
/** Deadline policy at `timeoutMs` expiry (default `'kill'`). */
onExpiry?: ShellExpiryPolicy | undefined
/**
* Foreground stdout capture budget in bytes. Absent uses the executor's
* default output cap. Trusted in-process consumers use this when they must
@@ -34,7 +36,7 @@ interface ShellExecRequest {
* tool does not expose it as a parameter.
*/
stdoutMaxBytes?: number | undefined
/** Abort signal — implementations kill the command when it fires. */
/** Abort signal — implementations kill the command when it fires, and treat a signal that is already aborted as fired. */
signal?: AbortSignal | undefined
/**
* Bytes to write to the command's stdin, then close it. Absent leaves stdin
@@ -68,19 +70,21 @@ interface ShellExecRequest {
```ts type-equiv
/**
* A resolved execution spec. {@link ShellExecutor.resolve} fills and caps the
* required fields; {@link ShellExecutor.start} ignores `timeoutMs` because
* background processes have no executor timeout.
* required fields; under `onExpiry: 'none'` the resolved `timeoutMs` arms no
* timer and is only echoed into {@link ShellRunResult.timeoutMs}.
*/
interface ShellExecSpec {
command: string
workdir: string
timeoutMs: number
/** Deadline policy at `timeoutMs` expiry ({@link ShellExecutor.resolve} defaults it to `'kill'`). */
onExpiry: ShellExpiryPolicy
/**
* Resolved foreground stdout capture budget in bytes. `run()` uses it for
* stdout; background jobs and stderr keep the executor's own output cap.
* Resolved stdout capture budget in bytes, applied to every execution's
* stdout; stderr keeps the executor's own output cap.
*/
stdoutMaxBytes: number
/** Abort signal — implementations kill the command when it fires. */
/** Abort signal — implementations kill the command when it fires, and treat a signal that is already aborted as fired. */
signal?: AbortSignal | undefined
/** Bytes to write to stdin before closing it; absent means no stdin. */
stdin?: string | undefined
@@ -244,37 +248,33 @@ Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnp
Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis' standard duplicate-service behavior).
execute resolves with the process handle after preparation. "Foreground" is a property of what the caller awaits, not of the spawn — a caller that awaits ShellExecution.result ran the command in the foreground; one that keeps the handle ran it in the background. A caller that waits only for a while runs the command under `onExpiry: 'none'` and bounds its own wait; the handle stays valid after the caller stops waiting.
Implementations must honor these semantics:
- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.
- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.
- ShellExecution.result rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a descriptive result: first-cause `timedOut`/`aborted`, the spec's `timeoutMs` echoed.
- The handle is published after preparation. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on the read path, while `result()` carries the same failure as its rejection.
- `onExpiry: 'none'` arms no deadline; `'kill'` kills at expiry. Expiry during preparation returns a settled timed-out handle without output.
- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.
- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.
- A still-running process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a process survives an executor-only reload.
```ts cordis-catalog
/**
* Apply implementation-owned defaults and caps to a request before execution.
* @param request - the caller's request; omitted fields get this
* implementation's defaults, capped fields are clamped.
* @returns the fully-specified spec to hand to {@link run}/{@link start}.
* @returns the fully-specified spec to hand to {@link execute}.
*/
abstract resolve(request: ShellExecRequest): ShellExecSpec
/**
* Run preparation and the foreground command under the resolved timeout.
* Prepare and spawn the command under its resolved deadline.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the outcome; nonzero exits, timeout kills, and abort kills
* resolve with a descriptive result rather than reject.
* @returns the prepared handle, including its result projection;
* preparation timeout yields an already-settled handle with no output.
* @throws on preparation failure or caller cancellation before process publication.
*/
abstract run(spec: ShellExecSpec): Promise<ShellRunResult>
/**
* Prepare a background process asynchronously and publish its live handle.
* @param spec - a resolved spec from {@link resolve}, never a raw request.
* @returns the live process handle after preparation; cancellation or setup failure rejects.
*/
abstract start(spec: ShellExecSpec): Promise<ShellProcess>
abstract execute(spec: ShellExecSpec): Promise<ShellExecution>
```
Source: [`packages/shell/shell/src/index.ts`](../../packages/shell/shell/src/index.ts)
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/tool-catalog.md
tool-catalog.md: c60d3979e65f3c5a2bc363fd082a3f63a446e8b5
tool-catalog.zh.md: dc061b51681cc73f9d10059276bcdfa5b3268cff
tool-catalog.md: 104329d7f472dc2273b802f26df9bd830858f187
tool-catalog.zh.md: 336ac892ad399a243dd2eb4921b0fe18e6bd64f1
+12 -8
View File
@@ -21,9 +21,9 @@ This table connects model-visible tool names to the plugin package and service s
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`, `ctx.userQuestions` | `tool/call`, `tool/result after a UI/provider answers the question` | - | ask_user_question pauses the tool call until the active UI provider returns a human answer. |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.ptcRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: ptc` / `mode: both` (see the PTC mode Agent Note). Under `ptc` it is the registry's only wire contribution; the other visible capabilities are declared in a generated SDK section in the loaded runtime's language, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. |
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userQuestions (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-questions seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter. |
| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented after a successful final result`, `tool/result` | - | Deliveries belong to the calling Session; Web ui-deliverables supplies source-file opening and cards. |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs at call time for run_in_background` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`, `ctx.shell`, `ctx.systemPrompt`, `ctx.shellEnv`, `ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`, `tool/result` | - | The pwsh tool is the PowerShell-dialect consumer of the bash executor seam for Windows compositions (a PowerShell executor such as `@deepseek-ai/dsh-pwsh-local` backs `ctx.shell`); it mirrors the bash tool call-for-call minus sandbox controls — `run_in_background` runs register with the generic `ctx.jobs` runtime and are collected/stopped through the `job_*` tools, and the managed `DSH_*` environment comes from `@deepseek-ai/dsh-shell-env`. Each call runs in a fresh process (no persistent PTY session), with native `C:\...` paths and `$env:NAME` variables. |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | Creator mode provides two read-only runtime inspection tools. The Cordis host runner supplies the inspection registry; Client queries require a connected page. Author persistent changes as bundles and install them with plugin_manager. |
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. |
| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`, `ctx.terminals`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent pwsh tool, the Windows counterpart of the persistent bash tool; deployment composition supplies a pwsh-dialect PTY backend and may override the model-facing environment description. |
@@ -588,7 +588,7 @@ exit_plan_mode stays in the model-facing schema while planning is inactive so tr
### `bash`
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.
Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
```json
{
@@ -604,7 +604,7 @@ Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
},
"workdir": {
"type": "string",
@@ -624,7 +624,7 @@ Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs
Source: [`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/src/index.ts)
The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.jobs` runtime and is collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled.
The bash tool is the model-facing consumer of the bash executor seam. With a job registry composed every call registers with the generic `ctx.jobs` runtime as it starts, collected/stopped through the `job_*` tools from `@deepseek-ai/dsh-tool-jobs`; without one, or with `enableRunInBackground: false`, the tool registers a foreground-only schema without the `run_in_background` parameter.
<a id="deepseek-aidsh-tool-present"></a>
@@ -675,7 +675,7 @@ Deliveries belong to the calling Session; Web ui-deliverables supplies source-fi
### `pwsh`
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`.
Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Each call runs in a fresh pwsh process: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Paths use native Windows form (`C:\...`); read environment variables with `$env:NAME`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$env:DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under <mode> mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. Set `run_in_background: true` for long-running commands: the call returns a job id immediately; read its output with `job_output` and stop it with `job_kill`. A foreground command that reaches its timeout is not killed: it moves to the background the same way, returning its job id and the output so far.
```json
{
@@ -691,7 +691,7 @@ Execute a PowerShell command (`pwsh -Command`) and return its stdout/stderr. Eac
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
},
"workdir": {
"type": "string",
@@ -2366,7 +2366,7 @@ Script-body hooks:
Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.
Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground by default: this call returns when the whole script finishes. Set `run_in_background: true` for a long run: the call returns a job id immediately, the run keeps orchestrating in the background, and its return value arrives with the job's completion notice (check on it with `job_output`, stop it with `job_kill`).
```json
{
@@ -2432,6 +2432,10 @@ Constraints: concurrency and total-agent caps apply; no filesystem, network, tim
"type": "object",
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
"additionalProperties": true
},
"run_in_background": {
"type": "boolean",
"description": "Run as a background job: return a job id immediately instead of waiting; the return value arrives with the completion notice."
}
},
"required": [
+12 -8
View File
@@ -25,9 +25,9 @@
| `@deepseek-ai/dsh-tool-ask-user` | `ask_user_question` | `ctx.tools`、`ctx.userQuestions` | `tool/call`、`tool/result after a UI/provider answers the question` | - | ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类答案。 |
| `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`、`ctx.ptcRuntime (execution time)`、`ctx.systemPrompt` | `tool/call`、`one tool/ptc-dispatch-start + tool/ptc-dispatch pair per bridged sub-call`、`tool/result` | - | 在 `mode: ptc`/`mode: both` 下,它由工具注册表所有,作为可过滤能力层之外的保留传输机制(参见 PTC mode Agent Note)。在 `ptc` 下,它是注册表对协议格式(wire format)的唯一贡献;其他可见能力在使用已加载运行时语言生成的 SDK 章节中声明。程序通过 binding 调用这些能力,调用按照原生并发约定调度:启动顺序和策略遵循提交顺序,并发安全的函数体最多重叠执行 `maxParallelSubCalls` 个。调用会重新进入完整且受守卫保护的工具流水线,并将每个嵌套执行关联到此外层结果。 |
| `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`、`ctx.systemPrompt`、`ctx.userQuestions (execution time, opportunistic)` | `tool/call`、`plan/mode inactive on an approved review`、`tool/result` | - | 规划未激活时,exit_plan_mode 仍保留在面向模型的 schema 中,这样状态转换不会在规划策略变更之外额外造成工具目录变动。其执行路径会拒绝规划模式之外的调用;在规划模式下,它通过用户交互 seam 提交计划(批准/根据反馈继续规划),批准后会在步骤边界记录规划模式已停用。 |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。 |
| `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。 |
| `@deepseek-ai/dsh-tool-present` | `present` | `ctx.tools`, `ctx.fs`, `ctx.sessionProjections` | `tool/call`, `deliverables/presented 在成功的最终结果之后`, `tool/result` | - | 交付归调用方 Session 所有;Web ui-deliverables 提供源文件打开与卡片。 |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs at call time for run_in_background` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
| `@deepseek-ai/dsh-tool-pwsh` | `pwsh` | `ctx.tools`、`ctx.shell`、`ctx.systemPrompt`、`ctx.shellEnv`、`ctx.jobs for run_in_background and the job-backed foreground path` | `tool/call`、`tool/result` | - | pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费方(由 `@deepseek-ai/dsh-pwsh-local` 等 PowerShell 执行器为 `ctx.shell` 提供后端);除沙箱接口外,它逐项对应 bash 工具调用。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具收集/停止;托管的 `DSH_*` 环境来自 `@deepseek-ai/dsh-shell-env`。每次调用都在新进程中运行,不使用持久 PTY 会话。路径采用原生 `C:\...` 形式,变量采用 `$env:NAME`。 |
| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect_list`, `cordis_inspect_query` | `ctx.tools`, `ctx.cordisInspect` | `tool/call`, `tool/result` | - | 创造模式提供两个只读运行时检查工具。Cordis host runner 提供检查注册表;Client 查询需要已连接页面。持久化变更编写为组合包,再通过 plugin_manager 安装。 |
| `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 |
| `@deepseek-ai/dsh-tool-pwsh-persistent` | `pwsh` | `ctx.tools`、`ctx.terminals`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 pwsh 工具,持久 bash 工具的 Windows 对应物;部署组合提供 pwsh 方言的 PTY 后端,并可覆盖面向模型的环境描述。 |
@@ -592,7 +592,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
### `bash`
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。
执行 bash 命令(`bash -c`)并返回 stdout/stderr。每次调用都在新 shell 中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
```json
{
@@ -608,7 +608,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
},
"workdir": {
"type": "string",
@@ -628,7 +628,7 @@ ask_user_question 会暂停工具调用,直到当前 UI 提供方返回人类
来源:[`packages/shell/tool-bash/src/index.ts`](../packages/shell/tool-bash/src/index.ts)
bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_background` 的运行会注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;禁用 `enableRunInBackground` 配置(默认为 true)后,该参数会被完全移除。
bash 工具是 bash 执行器 seam 面向模型的消费方。组合中有 job 注册表时,每次调用一启动就注册到通用 `ctx.jobs` 运行时,并通过 `job_*` 工具(来自 `@deepseek-ai/dsh-tool-jobs`)收集/停止;没有注册表或 `enableRunInBackground: false` 时,工具注册不带 `run_in_background` 参数的纯前台 schema。
<a id="deepseek-aidsh-tool-present"></a>
@@ -679,7 +679,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
### `pwsh`
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。
执行 PowerShell 命令(`pwsh -Command`)并返回 stdout/stderr。每次调用都在新的 pwsh 进程中运行:调用之间不保留任何状态(cwd、变量、函数),请传入 `workdir`,不要使用 `cd`。路径采用 Windows 原生形式(`C:\...`);使用 `$env:NAME` 读取环境变量。非零退出会报告为 `[exit code: N]`。当前 harness 环境信息通过托管的 `$env:DSH_*` 变量公开,需要时请检查这些变量。命令可能在文件沙箱中运行;被阻止的文件操作报告为 `[sandbox: file access denied under <mode> mode]`,这是策略拒绝,而不是命令缺陷,请勿换一种方式重试。较长的输出会截断,只保留尾部;如可用,完整输出会保存到文件并报告其路径。在 Windows 上,被强制终止的命令会以 `[exit code: 1]` 结算且不带信号标记,请将其视为中断,而不是命令失败。对于长时间运行的命令,请设置 `run_in_background: true`:调用会立即返回 job id;使用 `job_output` 读取输出,使用 `job_kill` 停止任务。到达超时的前台命令不会被杀:它以同样的方式转入后台,返回其 job id 与已捕获的输出。
```json
{
@@ -695,7 +695,7 @@ bash 工具是 bash 执行器 seam 面向模型的消费方。使用 `run_in_bac
},
"timeoutMs": {
"type": "number",
"description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."
"description": "Timeout in milliseconds. The executor applies its configured default and cap; on expiry the command moves to the background as a job instead of being killed."
},
"workdir": {
"type": "string",
@@ -2374,7 +2374,7 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
如果误用钩子(参数错误、未知选项、不受支持的 schema、触发上限),抛出的错误**总会**终止脚本,绝不会退化为单个条目的 `null`。
约束:并发上限和 agent 总数上限均会生效;不提供文件系统、网络、定时器或 Node.js API。具体工作由 agent 完成,脚本只负责编排。该运行在前台执行:整个脚本完成后,调用才会返回。
约束:并发上限和 agent 总数上限均会生效;不提供文件系统、网络、定时器或 Node.js API。具体工作由 agent 完成,脚本只负责编排。该运行默认在前台执行:整个脚本完成后,调用才会返回。长时间运行请设置 `run_in_background: true`:调用会立刻返回任务 id,运行在后台继续编排,其返回值随任务的完成播报送达(用 `job_output` 查看进展,用 `job_kill` 停止)。
```json
{
@@ -2440,6 +2440,10 @@ todo_write 是会话所有的状态;UI 将最新的 todo/write 事件渲染为
"type": "object",
"description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).",
"additionalProperties": true
},
"run_in_background": {
"type": "boolean",
"description": "Run as a background job: return a job id immediately instead of waiting; the return value arrives with the completion notice."
}
},
"required": [
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/api/job-controller/README.md
README.md: d3698758cc144c033037759a834944870948284d
README.zh.md: d9744b4d19cfff6d73923d0d26e3e817bc54c1a3
README.md: ea343a8a201b50901af0033b39662fab4dd50555
README.zh.md: ce8f166cec4e180e491cd48377f20db2e7177e75
+6 -4
View File
@@ -1,5 +1,5 @@
---
description: "Host and Client job streams: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor."
description: "Host and Client job control: the roster one session can see and one job's retained output, mirrored to the browser without touching the model's consuming cursor, and a kill on a human's behalf."
kind: "package-reference"
---
# Job Controller
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
## Summary
`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.list` mirrors the jobs one session can see as whole-set frames, and `job.follow` delivers one job's retained output from an absolute byte offset. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders. Neither stream touches the model's consuming cursor or the completion-notice ledger.
`@deepseek-ai/dsh-api-job-controller` owns the Host `ctx.jobController` service and the generated Client `ctx.remote.job` namespace. Its two Remote streams are projections of `ctx.jobs`: `job.list` mirrors the jobs one session can see as whole-set frames, and `job.follow` delivers one job's retained output from an absolute byte offset; its one command, `job.kill`, stops a job on a human's behalf. The Client half installs `ctx.jobs`, the reference-counted service whose rosters and accumulated views the session-header job list renders and whose `kill` its stop control calls. Neither stream touches the model's consuming cursor or its completion notices, and a human kill is not the model's own.
## Table of Contents
@@ -22,9 +22,11 @@ English | [中文](README.zh.md)
<a id="use-this-package"></a>
## Use this package
The Host controller requires the job registry (`dsh-jobs-local` in the shipped compositions) and fails to load without it. `job.list({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.follow({ sessionId?, jobId, from? })` passes `sessionId` to registry `get` and `readAt` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them.
The 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.list({ sessionId })` yields the session's visible set — its own jobs plus every unowned job — once on open and again after each coalesced burst of lifecycle commits (registration, progress, stopping, settlement, removal); output appends never refresh the roster, since a settled projection already carries the final byte count. `job.follow({ sessionId?, jobId, from? })` passes `sessionId` to registry `get` and `readAt` — an unowned job needs no session — and yields one `opened` anchor carrying the job projection, coalesced `output` frames, then one terminal `status` once the job has settled and its ring is drained, after which the stream closes normally; a removal announced mid-stream (the owner's teardown) closes it with the removed job's terminal projection. Both reads are non-consuming: the model's `job_output` cursor and notice state never observe them.
The Client entry installs `ctx.jobs` (`IJobs`), backed by the package-internal `ClientJobsModel`. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`.
`job.kill({ sessionId, jobId })` cancels one job the session can see with the reason `cancelled by the user`, which the registry merges into the killed job's detail. It is not a kill the model requested — `dsh-tool-jobs` withholds a notice only for the model's own `job_kill` and for settlements a live wait collected — so the owning agent's completion notice stays due, reason included, and a shell tool still waiting on that job reads the reason as `[stopped: cancelled by the user]` in its own result. It answers `{ outcome: 'requested' }` or `{ outcome: 'already-finished' }` and rejects an id the session cannot see as `job/not-found`; the registry's owner fence is the only access rule, so a child session's own jobs are killable from its list like any other.
The Client entry installs `ctx.jobs` (`IJobs`), backed by the package-internal `ClientJobsModel`. `kill(sessionId, jobId)` forwards to `job.kill` and returns the Remote result for the caller's admission verdict. `watchRows(sessionId)` keeps one roster stream open per watched session however many viewers hold it and drops the rows after the last release; a reconnect's first frame is already the whole truth. `observe(sessionId, jobId)` opens one Gateway stream per job however many viewers expand it, keeps a bounded render tail per job with `gapBefore` marking eviction or resume gaps, closes the view on the terminal frame or records a stream failure on it, and drops the view after the last viewer releases. The plugin resolves the Gateway stream factory and the `job` namespace while its own context is current, because stream (re)opens run on caller stacks whose dynamic context has not declared `remote.job`.
### Config
+6 -4
View File
@@ -1,5 +1,5 @@
---
description: "Host 与 Client 的 job 流:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标。"
description: "Host 与 Client 的 job 控制:把一个会话看得到的名册与一个 job 的保留输出镜像到浏览器,不触碰模型的消耗型游标,并代人类停止一个 job。"
kind: "package-reference"
---
# Job Controller
@@ -8,7 +8,7 @@ kind: "package-reference"
## 概述
`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.list` 以整集帧镜像一个会话看得到的 job;`job.follow` 从绝对字节偏移发送一个 job 的保留输出。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图。两条流都不触碰模型的消耗型游标与完成播报台账。
`@deepseek-ai/dsh-api-job-controller` 拥有 Host 的 `ctx.jobController` 服务与生成的 Client `ctx.remote.job` namespace。它的两条 Remote 流都是 `ctx.jobs` 的投影:`job.list` 以整集帧镜像一个会话看得到的 job;`job.follow` 从绝对字节偏移发送一个 job 的保留输出;它唯一的命令 `job.kill` 代人类停止一个 job。Client 半侧安装 `ctx.jobs`——按引用计数的服务,会话头部任务列表渲染其名册与累积视图,其停止控件调用它的 `kill`。两条流都不触碰模型的消耗型游标与完成通知,人类 kill 也不是模型自己的杀停。
## 目录
@@ -22,9 +22,11 @@ kind: "package-reference"
<a id="use-this-package"></a>
## 使用本包
Host 控制器要求 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少则不加载。`job.list({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.follow({ sessionId?, jobId, from? })` 把 `sessionId` 传给注册表的 `get` 与 `readAt`——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。
Host 控制器要求活体 Agent 注册表与 job 注册表(已发布组合里是 `dsh-jobs-local`),缺少任一则不加载。`job.list({ sessionId })` 产出该会话可见的集合——自己的 job 加上所有无主 job——打开时一次,之后每一轮聚合过的生命周期提交(注册、进度、停止中、结算、移除)后再一次;输出追加从不刷新名册,因为结算后的投影已经带着最终字节数。`job.follow({ sessionId?, jobId, from? })` 把 `sessionId` 传给注册表的 `get` 与 `readAt`——无主 job 不需要 session——先产出一个携带 job 投影的 `opened` 锚帧,再是聚合的 `output` 帧,job 结算且环排干后产出一个终态 `status`,随后流正常关闭;流中若通告了移除(拥有者 teardown),则以被移除 job 的终态投影收尾。两种读取都是非消耗的:模型侧 `job_output` 游标与完成播报状态永远观察不到它们。
Client 入口安装 `ctx.jobs`(`IJobs`),由包内部的 `ClientJobsModel` 支撑。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。
`job.kill({ sessionId, jobId })` 以 `cancelled by the user` 为原因取消一个该会话看得到的 job,注册表把该原因合并进被杀 job 的 detail。它不是模型请求的杀停——`dsh-tool-jobs` 只对模型自己的 `job_kill` 与存活等待收走的结算不发通知——因此拥有者 agent 的完成通知照常送达,并带上原因;仍在等待该 job 的 shell 工具则在自己的结果里以 `[stopped: cancelled by the user]` 读到原因。它回答 `{ outcome: 'requested' }` 或 `{ outcome: 'already-finished' }`,对该会话看不到的 id 以 `job/not-found` 拒绝;注册表的所有者围栏是唯一的访问规则,因此子会话自己的 job 也能像其他 job 一样从它的列表里被杀停。
Client 入口安装 `ctx.jobs`(`IJobs`),由包内部的 `ClientJobsModel` 支撑。`kill(sessionId, jobId)` 转发到 `job.kill` 并把 Remote 结果交给调用方判定准入。`watchRows(sessionId)` 不论多少查看器持有都只为每个被关注的会话开一条名册流,最后一个释放后丢弃行;重连后的首帧已经是完整事实。`observe(sessionId, jobId)` 不论多少查看器展开同一 job 都只开一条 Gateway 流,按 job 保留有界的渲染尾部并用 `gapBefore` 标记淘汰或续读缺口,在终态帧上关闭视图或把流失败记到视图上,最后一个查看器释放后丢弃视图。插件在自己的上下文仍是当前上下文时解析 Gateway 流工厂与 `job` namespace,因为流的(重)开启跑在未声明 `remote.job` 的调用栈上。
### 配置
@@ -1,6 +1,6 @@
/**
* Job Controller client half: installs `ctx.jobs` over the generated `job`
* Remote namespace. The plugin resolves both Remote faces it drives while its
* Job Controller client half: installs `ctx.jobs` (rosters, observations, and
* the human kill) over the generated `job` Remote namespace. The plugin resolves both Remote faces it drives while its
* own context is current, because stream (re)opens run on caller stacks — a
* React event, a carrier retry — whose dynamic context has not declared
* `remote.job`.
@@ -18,7 +18,7 @@ export type { JobsSnapshot, ObservedJob } from './model.ts'
// declaration emit drops the value import above.
export type { IJobs } from './service.ts'
export type {
JobChunk, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest, JobView,
JobChunk, JobKillRequest, JobKillValue, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest, JobView,
} from '../types.ts'
/** Required Client Remote services. */
@@ -3,15 +3,16 @@
* namespace — one `job.list` roster stream per watched session and one
* `job.follow` stream per observed job — so overlapping viewers share a
* stream, rosters resume whole after a reconnect, and observations resume
* from the model's cursor.
* from the model's cursor, plus the human kill passthrough over `job.kill`.
* @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 { RemoteResult } from '@deepseek-ai/dsh-typert-protocol'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from '../types.ts'
import type { JobKillRequest, JobKillValue, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from '../types.ts'
import type { ClientJobsModel, JobsSource } from './model.ts'
/** The generated `job` namespace face the stream runners drive. */
@@ -30,6 +31,12 @@ export interface JobRemote {
* @returns the frame sequence of one generation.
*/
follow(request: JobFollowRequest, signal?: AbortSignal): AsyncIterable<JobFollowFrame>
/**
* Kill one job on the human's behalf.
* @param request - the session whose list carries the job, and the job id.
* @returns the registry's admission, or the business/transport failure.
*/
kill(request: JobKillRequest): Promise<RemoteResult<JobKillValue>>
}
/** Remote faces the runners drive: the Gateway stream factory and the `job` namespace. */
@@ -57,6 +64,15 @@ export interface IJobs {
* @returns stop function releasing this observer's reference.
*/
observe(sessionId: SessionId | undefined, id: JobId): () => void
/**
* Kill one background job from a session's job list. Pure RPC passthrough:
* row state converges through the roster stream, and the caller (the
* job-list control) owns error presentation.
* @param sessionId - session whose job list carries the job.
* @param id - the job row's registry id.
* @returns the registry's admission, or the business/transport failure.
*/
kill(sessionId: SessionId, id: JobId): Promise<RemoteResult<JobKillValue>>
}
/** One reference-counted stream. */
@@ -104,6 +120,10 @@ export class ClientJobs extends Service implements IJobs {
}, 'job-controller.client.streams')
}
kill(sessionId: SessionId, id: JobId): Promise<RemoteResult<JobKillValue>> {
return this.remote.job.kill({ sessionId, jobId: id })
}
watchRows(sessionId: SessionId): () => void {
return this.acquire(
this.rowsEntries,
+38 -5
View File
@@ -1,18 +1,20 @@
/**
* Host job Remote owner: streams the background-job roster one session can
* see and one job's retained output to browsers over the generated `job`
* namespace. Both streams are projections of `ctx.jobs`; the model's
* consuming cursor and notice state never observe them.
* namespace, and stops a job on a human's behalf. The streams are
* projections of `ctx.jobs`; the model's consuming cursor and notice state
* never observe them, and a human kill is not the model's own, so the
* completion notice still reaches the owning agent.
* @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-jobs'
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
import { observeJobOutput } from './observe.ts'
import { streamJobRows } from './rows.ts'
import type { JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from './types.ts'
import type { JobKillRequest, JobKillValue, JobFollowFrame, JobFollowRequest, JobListFrame, JobListRequest } from './types.ts'
export type * from './types.ts'
@@ -50,7 +52,7 @@ export class JobController extends TypertRemoteService {
private readonly observeMaxFrameBytes: number
/**
* @param ctx - Host context carrying the job registry.
* @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) {
@@ -92,6 +94,37 @@ export class JobController extends TypertRemoteService {
maxFrameBytes: this.observeMaxFrameBytes,
}, signal)
}
/**
* Kill one background job on a human's behalf. The request's session is
* the fenced read's caller, so the job must be one that session can see:
* the registry's owner fence is the only access rule, and a child session's
* own jobs are killable from its list like any other. The kill records
* `cancelled by the user` as its reason; it is not one the model requested,
* so the owning agent still receives the completion notice, and a shell
* tool waiting on that job reads the reason in its own result.
* @param request - Session whose job list carries the job, and the job id.
* @returns the registry's admission of the kill request.
*/
@Remote('kill')
kill(request: JobKillRequest): JobKillValue {
const jobs = this.ctx.jobs
try {
jobs.get(request.jobId, request.sessionId)
} catch (error) {
// `unknown job` and `belongs to another session` both mean this session's
// list no longer carries a killable row; the client renders one story.
throw new RemoteError('job/not-found', String(error), {
sessionId: request.sessionId,
jobId: request.jobId,
})
}
// Same synchronous span as the lookup, so nothing can remove the job in
// between — and a producer-cancel throw propagates per the registry
// contract (job state unchanged) instead of masquerading as job-not-found.
const outcome = jobs.kill(request.jobId, request.sessionId, 'cancelled by the user')
return { outcome }
}
}
export default JobController
+21 -1
View File
@@ -1,6 +1,7 @@
/**
* Wire types of the generated `job` Remote namespace: the per-session roster
* stream and the per-job observation stream. Client-safe: no Host imports.
* stream, the per-job observation stream, and the human kill. Client-safe: no
* Host imports.
* @module @deepseek-ai/dsh-api-job-controller/types
*/
@@ -25,6 +26,25 @@ export interface JobListFrame {
readonly jobs: readonly JobView[]
}
/** Human-initiated cancellation of one background job visible to a session. */
export interface JobKillRequest {
/** Session whose job list carries the job; the fenced lookup reads as it. */
readonly sessionId: SessionId
readonly jobId: JobId
}
/** Receipt after the registry accepted the human kill request. */
export interface JobKillValue {
readonly outcome: 'requested' | 'already-finished'
}
declare module '@deepseek-ai/dsh-typert-protocol' {
interface RemoteErrorDetailsMap {
/** The session's job list no longer carries a killable row under that id. */
'job/not-found': { readonly sessionId: SessionId; readonly jobId: JobId }
}
}
/** Target of one `job.follow` stream: the job, its owning session, and an optional resume offset. */
export interface JobFollowRequest {
/**
@@ -14,11 +14,12 @@ afterEach(async () => {
})
/** Mount the client half over a captured `remote.job` namespace and a real Gateway stream carrier. */
async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCalls: unknown[] }> {
async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCalls: unknown[]; killCalls: unknown[] }> {
const ctx = new Context()
contexts.add(ctx)
const observeCalls: unknown[] = []
const rowsCalls: unknown[] = []
const killCalls: unknown[] = []
const connection: ConnectionHandle = {
isLoopback: true,
generation: { getSnapshot: () => ({ id: 1, host: { home: '/home/fixture' } }), subscribe: () => () => {} },
@@ -38,6 +39,10 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCal
observeCalls.push(request)
return (async function* (): AsyncGenerator<JobFollowFrame> {})()
},
kill: async (request: unknown) => {
killCalls.push(request)
return { ok: true as const, value: { outcome: 'requested' as const } }
},
}
ctx.reflect.provide('remote', {
$stream: <Item>(options: RemoteStreamOptions<Item>) => new RemoteStream(connection, options),
@@ -45,7 +50,7 @@ async function mount(): Promise<{ ctx: Context; observeCalls: unknown[]; rowsCal
})
ctx.reflect.provide('remote.job', job)
await ctx.plugin(JobClient)
return { ctx, observeCalls, rowsCalls }
return { ctx, observeCalls, rowsCalls, killCalls }
}
async function flush(): Promise<void> {
@@ -70,4 +75,11 @@ describe('Job Controller Client apply', () => {
await flush()
expect(ctx.jobs.state.getSnapshot()).toEqual({ rows: {}, observed: {} })
})
it('forwards a kill to the job namespace with the row\'s session', async () => {
const { ctx, killCalls } = await mount()
await expect(ctx.jobs.kill('session-1' as SessionId, 'bash-1' as never))
.resolves.toEqual({ ok: true, value: { outcome: 'requested' } })
expect(killCalls).toEqual([{ sessionId: 'session-1', jobId: 'bash-1' }])
})
})
@@ -0,0 +1,149 @@
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { JobId, JobOutcome } from '@deepseek-ai/dsh-jobs'
import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session } from '@deepseek-ai/dsh-session'
import { remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol'
import TypertRegistry from '@deepseek-ai/dsh-typert-registry'
import { describe, expect, it } from 'vitest'
import { JobController } from '../src/index.ts'
function producer(label = 'sleep 60') {
let settle!: (outcome: JobOutcome) => void
const cancels: (string | undefined)[] = []
const spec = {
kind: 'bash' as const,
label,
run: () => ({
cancel: (reason?: string) => { cancels.push(reason) },
done: new Promise<JobOutcome>((resolve) => { settle = resolve }),
}),
}
return { spec, cancels, settle: (outcome: JobOutcome) => { settle(outcome) } }
}
async function registerAgent(ctx: Context, session: Session): Promise<Agent> {
const agent = {
id: session.id,
session,
inbox: unsupportedInbox(),
status: 'idle',
ctx,
} as Agent
await ctx.agents.register(agent)
return agent
}
async function harness(): Promise<{
ctx: Context
session: Session
agent: Agent
controller: JobController
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(LocalJobRegistry)
ctx.jobs.attachController('kill-test')
await ctx.plugin(TypertRegistry)
await ctx.plugin(JobController, {})
const session = ctx.sessions.create()
const agent = await registerAgent(ctx, session)
return { ctx, session, agent, controller: ctx.jobController }
}
function failureCode(run: () => unknown): string {
try {
run()
} catch (error) {
const failure = remoteErrorOf(error)
if (failure !== undefined) return failure.code
throw error
}
throw new Error('expected a RemoteError failure')
}
describe('JobController.kill', () => {
it('kills an owned running job without claiming the terminal report', async () => {
const { ctx, session, agent, controller } = await harness()
const task = producer('pnpm run watch')
const id = ctx.jobs.start({ ...task.spec, owner: agent.id })
expect(controller.kill({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'requested' })
expect(task.cancels).toEqual(['cancelled by the user'])
// The kill reason is recorded for the killed detail; the notice ledger
// lives in tool-jobs, which never learns of this kill, so the completion
// notice stays due.
expect(ctx.jobs.get(id, agent.id)).toMatchObject({ status: 'stopping' })
})
it('reports an already-finished job instead of failing', async () => {
const { ctx, session, agent, controller } = await harness()
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent.id })
task.settle({ status: 'completed', detail: 'exit code: 0' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(controller.kill({ sessionId: session.id, jobId: id })).toEqual({ outcome: 'already-finished' })
expect(ctx.jobs.get(id, agent.id).status).toBe('completed')
})
it('kills an unowned job from a session without a live agent', async () => {
const { ctx, controller } = await harness()
const task = producer('unowned work')
const id = ctx.jobs.start(task.spec)
const cold = ctx.sessions.create()
expect(controller.kill({ sessionId: cold.id, jobId: id })).toEqual({ outcome: 'requested' })
})
it('rejects an unknown job id as job/not-found', async () => {
const { session, controller } = await harness()
expect(failureCode(() => controller.kill({ sessionId: session.id, jobId: 'bash-99' as JobId })))
.toBe('job/not-found')
})
it('rejects a foreign session\'s job as job/not-found', async () => {
const { ctx, agent, controller } = await harness()
const task = producer()
const id = ctx.jobs.start({ ...task.spec, owner: agent.id })
// The other session cannot see the owned job.
const other = ctx.sessions.create()
expect(failureCode(() => controller.kill({ sessionId: other.id, jobId: id })))
.toBe('job/not-found')
expect(ctx.jobs.get(id, agent.id).status).toBe('running')
})
it('propagates a producer cancel throw instead of masking it as job/not-found', async () => {
const { ctx, session, agent, controller } = await harness()
const id = ctx.jobs.start({
kind: 'bash',
label: 'flaky cancel',
owner: agent.id,
run: () => ({
cancel: () => { throw new Error('cancel boom') },
done: new Promise(() => {}),
}),
})
// The registry contract: a producer throw propagates with job state
// unchanged — the Remote must not rewrite it into a lookup failure.
expect(() => controller.kill({ sessionId: session.id, jobId: id })).toThrow('cancel boom')
expect(ctx.jobs.get(id, agent.id).status).toBe('running')
})
it('kills a child session\'s own job from its list: the owner fence is the only access rule', async () => {
const { ctx, controller } = await harness()
const child = ctx.sessions.create(undefined, { meta: { origin: 'subagent' } })
const childAgent = await registerAgent(ctx, child)
const task = producer('child work')
const id = ctx.jobs.start({ ...task.spec, owner: childAgent.id })
expect(controller.kill({ sessionId: child.id, jobId: id })).toEqual({ outcome: 'requested' })
expect(task.cancels).toEqual(['cancelled by the user'])
expect(ctx.jobs.get(id, childAgent.id).status).toBe('stopping')
})
})
@@ -253,6 +253,7 @@ function bench() {
const streams: { options: StreamOptions; stream: FakeStream<JobFollowFrame> & FakeStream<JobListFrame> }[] = []
const observeCalls: unknown[] = []
const rowsCalls: unknown[] = []
const killCalls: unknown[] = []
const remote = {
$stream: (options: StreamOptions) => {
const stream = new FakeStream<never>()
@@ -268,16 +269,26 @@ function bench() {
rowsCalls.push(request)
return { [Symbol.asyncIterator]: async function* () { /* never yields */ } }
},
kill: async (request: unknown) => {
killCalls.push(request)
return { ok: false as const, error: { code: 'job/not-found', message: 'gone' } }
},
},
}
const jobs = new ClientJobs(ctx, remote as never, model)
return { ctx, model, jobs, streams, observeCalls, rowsCalls }
return { ctx, model, jobs, streams, observeCalls, rowsCalls, killCalls }
}
const tick = () => new Promise<void>(resolve => setTimeout(resolve, 0))
const anchor = (): JobFollowFrame => ({ type: 'opened', job: view(), from: 0 })
describe('ClientJobs roster streams', () => {
it('forwards a kill to the job namespace and returns the Remote verdict', async () => {
const { jobs, killCalls } = bench()
await expect(jobs.kill(S1, ID)).resolves.toMatchObject({ ok: false })
expect(killCalls).toEqual([{ sessionId: 'alice', jobId: ID }])
})
it('shares one roster stream per session and drops the rows after the last release', async () => {
const { model, jobs, streams, rowsCalls } = bench()
const first = jobs.watchRows(S1)
@@ -15,6 +15,7 @@
"references": [
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../core/agent" },
{ "path": "../../core/session" },
{ "path": "../../jobs/jobs" },
{ "path": "../../typert/protocol" }
@@ -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: 6d8479ea7a88a1a68df559b04cfd13c9f44421cd
README.zh.md: 2591bbf86fba57deabc0a4282c868d84b24ec105
README.md: 684616e7e6011c125e8c4dd4b087d551522ab339
README.zh.md: 50b37df21b360a02edbbc099a433fc74e07ba3bd
+1 -1
View File
@@ -8,7 +8,7 @@ English | [中文](README.zh.md)
## Summary
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `session`, `skills`, and `fileReferences` Remote namespaces. It serves Session lifecycle and history, the Host-generation model catalog, human background-job kill, workspace-path opening, user-invocable skill discovery, and Agent-scoped file references. Use it through API Gateway when a Client needs operations addressed by a Session.
## Table of Contents
+1 -1
View File
@@ -8,7 +8,7 @@ kind: "package-reference"
## 概述
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、工作区路径打开、用户可调用 skill(技能)发现和 Agent(智能体)范围的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务,以及生成的 Client `session`、`skills` 和 `fileReferences` Remote namespace。它提供 Session 生命周期与历史、Host generation 模型目录、人工后台任务终止、工作区路径打开、用户可调用 skill(技能)发现和 Agent(智能体)范围的文件引用。当 Client 需要按 Session 寻址的操作时,请通过 API Gateway 使用它。
## 目录
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-jobs/README.md
README.md: b6c8f39d40ca4630313659a94adc305f4c379119
README.zh.md: 871948194dfcc9a5f0a9ab1f795556944b19144f
README.md: 6f2b856a970cc869676bbda57d749cf4fb7ceba0
README.zh.md: b99723e841cfb3d6c52ba0ead7e1ab4dd1952e51
+3 -2
View File
@@ -31,6 +31,8 @@ Load the plugin through the web-app manifest; it renders nothing until the sessi
The `job.list` stream `ctx.jobs` mirrors is the single roster: each `JobView` row carries lifecycle, duration, the live `progress` line or the terminal `detail`, and its retained byte count — a live job, or a settled one with retained output, is what makes a row expandable. There is no second roster to join.
Running job rows also carry a two-press stop control: the first press arms it, the confirming press within three seconds calls `ctx.jobs.kill`, and the row converges through the roster stream (`stopping`, then the settled section, whose detail carries `cancelled by the user`). The kill claims nothing in the model's notice ledger, so the owning agent still receives the standard completion notice — the model is told the user stopped its task rather than left to infer it ([decision](../../../.agents/notes/implemented/feature/2026-08-26-human-job-kill.md)). The settled section folds behind its count while live work exists and can be cleared client-side.
### The expanded panel
Expanding an observable row opens that job's output observation stream from `ctx.jobs` (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 interruptions render as notices above the panel.
@@ -58,7 +60,7 @@ One slot entry in the header actions band (after the preset label, before the su
<a id="further-exploration"></a>
## Further Exploration
- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.list` and `job.follow` streams and the `ctx.jobs` service behind the rows and the panel.
- [`dsh-api-job-controller`](../../api/job-controller/README.md) — the `job.list` and `job.follow` streams, the `job.kill` Remote, and the `ctx.jobs` service behind the rows, the panel, and the stop control.
- [`dsh-jobs`](../../jobs/jobs/README.md) — the registry contract that owns the ring and projection semantics.
- [`dsh-client-ui-primitives`](../ui-primitives/README.md) — the `TerminalBlock` surface the panel configures.
@@ -79,7 +81,6 @@ None; the package never assembles or sends provider requests.
These limits define current package constraints, not a task backlog.
- **No kill control** — cancellation stays with the model's `job_kill`.
- **Channel labels are not rendered** — stdout and stderr chunks concatenate into one stream; per-channel tinting is a presentation follow-up.
<a id="dev-note"></a>
+3 -2
View File
@@ -31,6 +31,8 @@ kind: "package-reference"
`ctx.jobs` 镜像的 `job.list` 流是唯一名册:每个 `JobView` 行携带生命周期、时长、实时 `progress` 行或终态 `detail`,以及其保留字节数——进行中的 job,或留有保留输出的已结束 job,就是行可展开的依据。不存在需要 join 的第二份名册。
运行中的 job 行还带一个两击式停止控件:首击武装,三秒内的确认击调用 `ctx.jobs.kill`,行状态经名册流收敛(先 `stopping`,再入已结束分组,其 detail 携带 `cancelled by the user`)。该 kill 不在模型的播报台账里认领任何东西,任务的 owner agent 因此照常收到标准完成通知——模型被明确告知用户停止了它的任务,而不是留给它去猜([决策](../../../.agents/notes/implemented/feature/2026-08-26-human-job-kill.zh.md))。已结束分组在有进行中工作时折叠在其计数之后,并可在客户端清空。
### 展开的面板
展开可观察的行会从 `ctx.jobs`(由 `dsh-api-job-controller` 安装)打开该 job 的输出观测流,注入内嵌终端面板。面板复制的是命令(不是输出),命令与输出行完整换行,输出在固定高度内滚动而非折叠,且不绘制自己的运行状态点——上方的行承载状态。保留缺口与流中断在面板上方渲染为提示。
@@ -58,7 +60,7 @@ kind: "package-reference"
<a id="further-exploration"></a>
## 进一步探索
- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 行与面板背后的 `job.list`、`job.follow` 流与 `ctx.jobs` 服务。
- [`dsh-api-job-controller`](../../api/job-controller/README.zh.md) —— 行、面板与停止控件背后的 `job.list`、`job.follow` 流、`job.kill` Remote 与 `ctx.jobs` 服务。
- [`dsh-jobs`](../../jobs/jobs/README.zh.md) —— 拥有环与投影语义的注册表契约。
- [`dsh-client-ui-primitives`](../ui-primitives/README.zh.md) —— 面板所配置的 `TerminalBlock` 表面。
@@ -79,7 +81,6 @@ kind: "package-reference"
这些限制界定当前包约束,不是任务清单。
- **没有 kill 控件**——取消仍归模型的 `job_kill`。
- **不渲染 channel 标签**——stdout 与 stderr 块拼接为一条流;按 channel 着色是展示层的后续工作。
<a id="dev-note"></a>
+1
View File
@@ -43,6 +43,7 @@
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-session": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-jobs": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@types/react": "~18.3.1",
"react": "^18.2.0"
@@ -47,8 +47,8 @@
display: flex;
flex-direction: column;
gap: 1px;
width: 440px;
max-width: min(520px, calc(100vw - 32px));
width: 500px;
max-width: min(560px, calc(100vw - 32px));
max-height: min(480px, calc(100vh - 140px));
margin: 0;
padding: 3px;
@@ -66,8 +66,11 @@
/* Section labels over the live rows and the settled tail. */
.sectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 4px 1px;
padding: 4px 4px 3px;
padding: 4px 0 3px;
border-top: 0.5px solid var(--dsw-alias-border-l1);
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
@@ -80,11 +83,146 @@
border-top: 0;
}
/* Fold toggle and clear action on the settled section line. */
.sectionToggle,
.sectionClear {
display: inline-flex;
gap: 3px;
align-items: center;
padding: 2px 4px;
border: 0;
border-radius: 5px;
background: transparent;
color: var(--dsw-alias-label-tertiary);
font-size: 11px;
line-height: 16px;
cursor: pointer;
}
.sectionToggle:hover,
.sectionToggle:focus-visible,
.sectionClear:hover,
.sectionClear:focus-visible {
background: var(--dsw-alias-fill-l1);
color: var(--dsw-alias-label-secondary);
}
.sectionChevron {
transform: rotate(-90deg);
transition: transform 120ms ease;
}
.sectionChevronOpen {
transform: none;
}
.item {
display: flex;
flex-direction: column;
}
/* Row plus its optional kill button; the row button keeps its full-width hit
area by flexing, so non-job rows render identically to before. */
.rowLine {
display: flex;
gap: 6px;
align-items: stretch;
}
/* Live rows read as filled cards so running work outranks the settled tail;
the inner padding frames the row and both trailing controls on the fill,
and the hover wash sits on the card so the controls never escape it. */
.rowLineLive {
padding: 4px 8px 4px 2px;
border-radius: 10px;
background: var(--dsw-alias-fill-l2);
}
.rowLineLive:hover {
background: color-mix(in srgb, var(--dsw-alias-label-primary) 9%, transparent);
}
/* The kind badge shares the card fill, so it pops on the menu surface instead. */
.rowLineLive .kind {
background: var(--dsw-specific-menu);
}
.rowLine .row {
flex: 1;
min-width: 0;
}
/* The card carries the hover wash; the inner row stays transparent so the
two washes never stack, keeping keyboard focus its own visible state. */
.rowLineLive .row:hover {
background: transparent;
}
.rowLineLive .row:focus-visible {
background: color-mix(in srgb, var(--dsw-alias-label-primary) 6%, transparent);
}
/* The expand affordance on live rows: a persistent bordered square, so the
control reads as a button before hover (it lives inside the row toggle). */
.chevronBox {
display: inline-flex;
flex: none;
align-items: center;
align-self: center;
justify-content: center;
width: 20px;
height: 20px;
border: 0.5px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-specific-menu);
}
/* Two-press kill affordance on running job rows: same persistent square. */
.stop {
display: inline-flex;
flex: none;
align-items: center;
align-self: center;
justify-content: center;
width: 20px;
height: 20px;
padding: 0;
border: 0.5px solid var(--dsw-alias-border-l2);
border-radius: 6px;
background: var(--dsw-specific-menu);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.stop:hover,
.stop:focus-visible {
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
color: var(--dsw-alias-state-error-primary);
}
/* The armed press widens into a labeled danger pill so the confirm step is
legible without hover; the state-error token is the one StateDot uses. */
.stopArmed,
.stopArmed:hover,
.stopArmed:focus-visible {
gap: 4px;
width: auto;
padding: 0 7px;
border-color: color-mix(in srgb, var(--dsw-alias-state-error-primary) 45%, transparent);
background: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent);
color: var(--dsw-alias-state-error-primary);
}
.stopLabel {
font-size: 11px;
line-height: 18px;
white-space: nowrap;
}
.stopFailed {
color: var(--dsw-alias-state-error-primary);
}
.row {
display: flex;
align-items: center;
@@ -223,5 +361,5 @@
}
.noticeError {
color: var(--dsw-alias-label-error, var(--dsw-alias-label-secondary));
color: var(--dsw-alias-state-error-primary);
}
@@ -2,7 +2,7 @@ import { useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEve
import type { JobsSnapshot, JobView, ObservedJob } from '@deepseek-ai/dsh-api-job-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import {
IconChevronDownOutlineRegular, StateDot, TerminalBlock, useDismissOnOutsidePointer,
IconChevronDownOutlineRegular, IconStopFillRegular, StateDot, TerminalBlock, useDismissOnOutsidePointer,
type StateDotState, type TerminalBlockLabels,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { InjectFace, PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -29,6 +29,12 @@ export interface JobListInjected {
* Reference-counted by the client service, so panels can overlap safely.
*/
observe: (sessionId: SessionId | undefined, id: JobView['id']) => () => void
/**
* Kill one background job on the human's behalf. Resolves `true` when the
* registry admitted the request (`requested` or `already-finished`); row
* state itself converges through the jobs control frames.
*/
killJob: (sessionId: SessionId, jobId: string) => Promise<boolean>
}
/** Full props for the session-header job-list action. */
@@ -43,6 +49,12 @@ const NO_JOBS: readonly JobView[] = []
/** Minimum gap kept between the popover and the viewport edges (the Menu primitive's portal margin). */
const VIEWPORT_MARGIN = 12
/** How long an armed kill waits for its confirming press before disarming. */
const KILL_ARM_MS = 3_000
/** How long a failed kill keeps its hint before the button resets. */
const KILL_FAILED_MS = 4_000
function isLive(job: JobView): boolean {
return job.status === 'running' || job.status === 'stopping'
@@ -153,14 +165,24 @@ function ordered(jobs: readonly JobView[]): JobView[] {
})
}
/**
* Two-press kill affordance state: `armed` waits for the confirming second
* press (and disarms on a timer), `pending` covers the in-flight RPC until the
* row's own status flip removes the button, `failed` shows briefly after a
* rejected kill.
*/
type KillState = 'idle' | 'armed' | 'pending' | 'failed'
/** One job row plus, when observable and expanded, its live output panel. */
function JobItem({ job, view, expanded, now, onToggle, t }: {
function JobItem({ job, view, expanded, now, onToggle, kill, t }: {
job: JobView
view: ObservedJob | undefined
expanded: boolean
/** Clock sample live rows derive their running duration from. */
now: number
onToggle: () => void
/** Present on running rows: the human-kill button state and press handler. */
kill?: { state: KillState; onPress: () => void }
t: TranslateNS<typeof NS>
}) {
const live = isLive(job)
@@ -185,15 +207,17 @@ function JobItem({ job, view, expanded, now, onToggle, t }: {
<span className={css.main}>
<span className={css.primary}>
<span className={css.label} title={job.label}>{job.label}</span>
{durationCell}
</span>
<span className={css.secondary}>
<span className={css.secondary} title={detail ?? status}>
<span className={css.kind}>{job.kind}</span>
<span className={css.status} title={detail ?? status}>{detail ?? status}</span>
{detail !== undefined ? <span className={css.status}>{detail}</span> : null}
{durationCell}
</span>
</span>
{/* A live row is always observable: its output may still arrive. */}
<IconChevronDownOutlineRegular size={14} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
<span className={css.chevronBox}>
<IconChevronDownOutlineRegular size={12} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
</span>
</>
)
: (
@@ -203,28 +227,63 @@ function JobItem({ job, view, expanded, now, onToggle, t }: {
<span className={css.label} title={job.label}>{job.label}</span>
<span className={css.status} title={detail ?? status}>{detail ?? status}</span>
{durationCell}
{observable ? <IconChevronDownOutlineRegular size={14} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} /> : null}
{observable
? (
<span className={css.chevronBox}>
<IconChevronDownOutlineRegular size={12} className={expanded ? `${css.chevron} ${css.chevronOpen}` : css.chevron} />
</span>
)
: null}
</>
)
const killTitle = kill === undefined
? undefined
: kill.state === 'armed'
? t('kill.confirm')
: kill.state === 'failed' ? t('kill.failed') : t('kill.stop', { label: job.label })
return (
<li className={css.item}>
{observable
? (
<button
type="button"
className={live ? css.row : `${css.row} ${css.rowSettled}`}
aria-expanded={expanded}
aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: job.label })}
onClick={onToggle}
>
{body}
</button>
)
: (
<span className={`${css.row} ${css.rowSettled} ${css.rowStatic}`}>
{body}
</span>
)}
<div className={live ? `${css.rowLine} ${css.rowLineLive}` : css.rowLine}>
{observable
? (
<button
type="button"
className={live ? css.row : `${css.row} ${css.rowSettled}`}
aria-expanded={expanded}
aria-label={t(expanded ? 'row.collapseAria' : 'row.expandAria', { label: job.label })}
onClick={onToggle}
>
{body}
</button>
)
: (
<span className={`${css.row} ${css.rowSettled} ${css.rowStatic}`}>
{body}
</span>
)}
{kill !== undefined
? (
<button
type="button"
className={
kill.state === 'armed'
? `${css.stop} ${css.stopArmed}`
: kill.state === 'failed' ? `${css.stop} ${css.stopFailed}` : css.stop
}
data-kill-state={kill.state}
disabled={kill.state === 'pending'}
aria-label={killTitle}
title={killTitle}
onClick={kill.onPress}
>
<IconStopFillRegular size={10} />
{/* The armed press must be legible without hover: the button
widens into a labeled confirm pill instead of a tint only. */}
{kill.state === 'armed' ? <span className={css.stopLabel}>{t('kill.confirmAction')}</span> : null}
</button>
)
: null}
</div>
{expanded && view !== undefined
? (
<div className={css.panel}>
@@ -257,17 +316,26 @@ function JobItem({ job, view, expanded, now, onToggle, t }: {
* session can see at least one job. Expanding an observable row (a live job,
* or a settled one with retained output) starts its observation stream, and
* collapsing (or closing the popover) stops it — output only flows while
* someone is watching.
* @param props - runtime slot currency, the jobs snapshot hook, the roster
* and observation controls, and the namespace translator.
* someone is watching. A running row carries a two-press stop button that
* requests a human kill through the job controller.
* @param props - runtime slot currency, the jobs snapshot hook, the roster,
* observation, and kill controls, and the namespace translator.
* @returns the trigger and its popover list, or null when there is nothing to show.
*/
export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: JobListActionProps) {
export function JobListAction({ sessionId, useJobs, watchRows, observe, killJob, t }: JobListActionProps) {
const jobs = useJobs(state => state.rows[sessionId]) ?? NO_JOBS
const observedViews = useJobs(state => state.observed)
const [open, setOpen] = useState(false)
const [expandedKey, setExpandedKey] = useState<string | undefined>(undefined)
const [now, setNow] = useState(() => Date.now())
// Settled-section fold: an explicit user toggle wins; before one, the tail
// folds only while live work exists (a settled-only list opens expanded).
const [settledOpen, setSettledOpen] = useState<boolean | undefined>(undefined)
// Rows the user cleared from the settled tail (client-side hide only; the
// registry keeps its records and a later settlement reappears normally).
const [clearedKeys, setClearedKeys] = useState<ReadonlySet<string>>(() => new Set())
// One kill affordance advances at a time: arming a row disarms any other.
const [killPhase, setKillPhase] = useState<{ key: string; state: Exclude<KillState, 'idle'> } | undefined>(undefined)
const rootRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const menuRef = useRef<HTMLUListElement>(null)
@@ -277,7 +345,12 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
const rows = useMemo(() => ordered(jobs), [jobs])
const liveRows = useMemo(() => rows.filter(isLive), [rows])
const settledRows = useMemo(() => rows.filter(job => !isLive(job)), [rows])
const settledRows = useMemo(
() => rows.filter(job => !isLive(job) && !clearedKeys.has(String(job.id))),
[rows, clearedKeys],
)
const settledExpanded = settledOpen ?? liveRows.length === 0
const visibleCount = liveRows.length + settledRows.length
useDismissOnOutsidePointer(rootRef, open, setOpen)
@@ -330,11 +403,11 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
return observe(sessionId, activeJob)
}, [sessionId, activeJob, observe])
// The last job disappearing removes this control; close first so focus
// does not vanish from an unmounting node.
// The last visible job disappearing removes this control; close first so
// focus does not vanish from an unmounting node.
useEffect(() => {
if (rows.length === 0 && open) setOpen(false)
}, [rows.length, open])
if (visibleCount === 0 && open) setOpen(false)
}, [visibleCount, open])
// An expanded row that left the list (owner disposal) folds its panel.
useEffect(() => {
@@ -343,12 +416,64 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
}
}, [rows, expandedKey])
if (rows.length === 0) return null
// An armed kill disarms on a timer, and a failed one clears its hint; the
// pending phase instead waits for the RPC (or the row's own status flip).
// The cleanup clears the timer on every phase change, so a firing timer
// always describes the current phase and may reset unconditionally.
useEffect(() => {
if (killPhase === undefined || killPhase.state === 'pending') return
const timer = setTimeout(
() => { setKillPhase(undefined) },
killPhase.state === 'armed' ? KILL_ARM_MS : KILL_FAILED_MS,
)
return () => { clearTimeout(timer) }
}, [killPhase])
// A phase whose row stopped being killable (settled, stopping, removed)
// has no button to describe any more.
useEffect(() => {
if (killPhase !== undefined
&& !rows.some(job => String(job.id) === killPhase.key && job.status === 'running')) {
setKillPhase(undefined)
}
}, [rows, killPhase])
const pressKill = (job: JobView): void => {
const key = String(job.id)
if (killPhase?.key !== key || killPhase.state !== 'armed') {
setKillPhase({ key, state: 'armed' })
return
}
setKillPhase({ key, state: 'pending' })
void killJob(sessionId, key).then((ok) => {
// An admitted kill stays pending: the unary response (HTTP) and the jobs
// frames (control stream) have no cross-carrier ordering, so re-enabling
// here could offer a duplicate kill while the row still reads `running`.
// The authoritative jobs frame flips the row to `stopping`, which removes
// the button and clears the phase through the killable-set effect above.
setKillPhase(current => current?.key === key && !ok
? { key, state: 'failed' }
: current)
})
}
if (visibleCount === 0) return null
const countKey = liveRows.length > 0
? (liveRows.length === 1 ? 'count.live.one' : 'count.live.other')
: (rows.length === 1 ? 'count.idle.one' : 'count.idle.other')
const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : rows.length })
: (visibleCount === 1 ? 'count.idle.one' : 'count.idle.other')
const countLabel = t(countKey, { count: liveRows.length > 0 ? liveRows.length : visibleCount })
const clearSettled = (): void => {
setClearedKeys((current) => {
const next = new Set(current)
for (const job of settledRows) next.add(String(job.id))
return next
})
if (expandedKey !== undefined && settledRows.some(job => String(job.id) === expandedKey)) {
setExpandedKey(undefined)
}
}
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
if (event.key !== 'Escape' || !open) return
@@ -367,6 +492,14 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
onToggle={() => {
setExpandedKey(current => current === String(job.id) ? undefined : String(job.id))
}}
{...job.status === 'running'
? {
kill: {
state: killPhase?.key === String(job.id) ? killPhase.state : 'idle' as const,
onPress: () => { pressKill(job) },
},
}
: {}}
t={t}
/>
)
@@ -400,9 +533,24 @@ export function JobListAction({ sessionId, useJobs, watchRows, observe, t }: Job
: null}
{liveRows.map(item)}
{settledRows.length > 0
? <li className={css.sectionHeader} aria-hidden="true">{t('section.settled')}</li>
? (
<li className={css.sectionHeader}>
<button
type="button"
className={css.sectionToggle}
aria-expanded={settledExpanded}
onClick={() => { setSettledOpen(!settledExpanded) }}
>
<IconChevronDownOutlineRegular size={12} className={settledExpanded ? `${css.sectionChevron} ${css.sectionChevronOpen}` : css.sectionChevron} />
{t('section.settledCount', { count: settledRows.length })}
</button>
<button type="button" className={css.sectionClear} onClick={clearSettled}>
{t('section.clear')}
</button>
</li>
)
: null}
{settledRows.map(item)}
{settledExpanded ? settledRows.map(item) : null}
</ul>
)
: null}
+8 -4
View File
@@ -1,10 +1,11 @@
/**
* Background-job plugin, browser half: contributes one session-header action
* that renders this session's jobs. Job rows and per-row observation streams
* both arrive through the `jobs` client service; this plugin holds no
* transport state of its own.
* that renders this session's jobs. Job rows, per-row observation streams,
* and the human kill all go through the `jobs` client service; this plugin
* holds no transport state of its own.
*/
import type { Context as ClientContext } from '@deepseek-ai/cordis'
import type { JobId } from '@deepseek-ai/dsh-jobs/brand'
import { JobListAction } from './JobListAction.tsx'
import type { JobListInjected } from './JobListAction.tsx'
import type {} from '@deepseek-ai/dsh-api-job-controller/client'
@@ -22,7 +23,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
export type { JobListActionProps, JobListInjected } from './JobListAction.tsx'
/** Required services: the jobs rosters and observations, the slot registry, and dictionaries. */
/** Required services: the jobs rosters, observations, and kill, the slot registry, and dictionaries. */
export const inject = ['jobs', 'slots', 'locale']
/**
@@ -44,6 +45,9 @@ export function apply(ctx: ClientContext): void {
hooks: { jobs: ctx.jobs.state },
watchRows: sessionId => ctx.jobs.watchRows(sessionId),
observe: (sessionId, id) => ctx.jobs.observe(sessionId, id),
// The brand is nominal typing only; the row key is the registry id the
// roster stream delivered, so the wire boundary stamps it back here.
killJob: async (sessionId, jobId) => (await ctx.jobs.kill(sessionId, jobId as JobId)).ok,
}),
}, JobListAction),
)
+12 -2
View File
@@ -11,9 +11,14 @@ export const zh = {
'count.idle.other': '{count} 个后台任务',
'list.aria': '后台任务',
'section.live': '进行中',
'section.settled': '已结束',
'section.settledCount': '已结束 {count}',
'section.clear': '清空',
'row.expandAria': '展开 {label} 的实时输出',
'row.collapseAria': '收起 {label} 的实时输出',
'kill.stop': '停止任务 {label}',
'kill.confirm': '再次点击确认停止',
'kill.confirmAction': '确认停止',
'kill.failed': '停止失败',
'status.running': '运行中',
'status.stopping': '正在停止',
'status.completed': '已完成',
@@ -49,9 +54,14 @@ export const en: Record<JobKey, string> = {
'count.idle.other': '{count} background jobs',
'list.aria': 'Background jobs',
'section.live': 'Running',
'section.settled': 'Finished',
'section.settledCount': 'Finished {count}',
'section.clear': 'Clear',
'row.expandAria': 'Show live output of {label}',
'row.collapseAria': 'Hide live output of {label}',
'kill.stop': 'Stop task {label}',
'kill.confirm': 'Click again to confirm',
'kill.confirmAction': 'Confirm stop',
'kill.failed': 'Stop failed',
'status.running': 'running',
'status.stopping': 'stopping',
'status.completed': 'completed',
@@ -28,6 +28,10 @@ const observed: [string | undefined, string][] = []
/** Roster watches handed to the stubbed jobs service. */
const watched: string[] = []
/** Job ids the stubbed bound session was asked to kill, and its scripted result. */
const kills: [string, string][] = []
let killResult: { ok: boolean } = { ok: true }
/** Boot the browser half over a real slot tree that declares the header list. */
async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugin']> }> {
const ctx = new Context()
@@ -48,6 +52,10 @@ async function bench(): Promise<{ ctx: Context; fiber: ReturnType<Context['plugi
observed.push([sessionId, id])
return () => {}
},
kill: async (sessionId: string, jobId: string) => {
kills.push([sessionId, jobId])
return killResult
},
} as never)
// The locale plugin binds a settings scope, which reads the connection handle
// and the forwarded-event port.
@@ -69,7 +77,7 @@ describe('ui-jobs browser half', () => {
expect(inject).toEqual(['jobs', 'slots', 'locale'])
})
it('exposes the jobs source and the roster and observation controls through the inject face', async () => {
it('exposes the jobs source and the roster, observation, and kill controls through the inject face', async () => {
const { ctx } = await bench()
const entry = ctx.slots
.entries('conversation.session.header.actions')
@@ -84,6 +92,14 @@ describe('ui-jobs browser half', () => {
const stopper = face.observe(SessionId('session'), JobId('bash-1'))
expect(observed).toEqual([['session', 'bash-1']])
stopper()
// The kill control routes through the job service with the row's session
// and reports the admission verdict.
killResult = { ok: true }
await expect(face.killJob(SessionId('sess-live'), 'bash-3')).resolves.toBe(true)
killResult = { ok: false }
await expect(face.killJob(SessionId('sess-live'), 'bash-4')).resolves.toBe(false)
expect(kills).toEqual([['sess-live', 'bash-3'], ['sess-live', 'bash-4']])
})
it('registers the header action, and fiber teardown removes it (HMR safety)', async () => {
@@ -38,13 +38,14 @@ function props(
jobs: readonly JobView[],
observe: JobListActionProps['observe'] = () => () => {},
observed: Readonly<Record<string, ObservedJob>> = {},
killJob: JobListActionProps['killJob'] = async () => true,
watchRows: JobListActionProps['watchRows'] = () => () => {},
): JobListActionProps {
const jobsState: JobsSnapshot = { rows: jobs.length > 0 ? { [SESSION]: jobs } : {}, observed }
function useJobs<T>(select: (value: JobsSnapshot) => T): T {
return select(jobsState)
}
return { sessionId: SESSION, useJobs, watchRows, observe, t } as JobListActionProps
return { sessionId: SESSION, useJobs, watchRows, observe, killJob, t } as JobListActionProps
}
function openList(): void {
@@ -60,7 +61,7 @@ describe('JobListAction visibility', () => {
it('watches the session roster while mounted and releases it on unmount', () => {
const stop = vi.fn()
const watchRows = vi.fn(() => stop)
const { unmount } = render(<JobListAction {...props([], undefined, {}, watchRows)} />)
const { unmount } = render(<JobListAction {...props([], undefined, {}, undefined, watchRows)} />)
expect(watchRows).toHaveBeenCalledWith(SESSION)
expect(stop).not.toHaveBeenCalled()
unmount()
@@ -69,7 +70,7 @@ describe('JobListAction visibility', () => {
it('counts live jobs on the trigger', () => {
render(<JobListAction {...props([
job(),
outputJob(),
job({ id: 'subagent-1' as JobView['id'], kind: 'subagent', label: 'explore' }),
])} />)
expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined()
@@ -104,21 +105,49 @@ describe('JobListAction rows', () => {
render(<JobListAction {...props([job({ status: 'stopping', progress: 'winding down' })])} />)
openList()
const list = screen.getByRole('list', { name: zh['list.aria'] })
expect(within(list).getAllByRole('listitem')).toHaveLength(1)
expect(within(list).getByText('pnpm run build')).toBeDefined()
expect(within(list).getByText('winding down')).toBeDefined()
})
it('labels each non-empty section and drops the heading of an empty one', () => {
const settled = [job({ id: 'bash-2' as JobView['id'], status: 'completed', finishedAt: 1_700_000_012_000 })]
const { rerender } = render(<JobListAction {...props([job(), ...settled])} />)
it('folds the settled tail behind its count while live work exists, and clears on demand', () => {
const settled = [job({ id: 'bash-2' as JobView['id'], label: 'settled work', status: 'completed', finishedAt: 1_700_000_012_000 })]
render(<JobListAction {...props([job(), ...settled])} />)
openList()
expect(screen.getByText(zh['section.live'])).toBeDefined()
expect(screen.getByText(zh['section.settled'])).toBeDefined()
// A live-only list keeps its own heading and drops the settled one.
rerender(<JobListAction {...props([job()])} />)
expect(screen.getByText(zh['section.live'])).toBeDefined()
expect(screen.queryByText(zh['section.settled'])).toBeNull()
const toggle = screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') })
// Folded by default while something runs; the toggle expands it.
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByText('settled work')).toBeNull()
fireEvent.click(toggle)
expect(screen.getByText('settled work')).toBeDefined()
// Clearing hides the tail client-side and drops the whole section line.
fireEvent.click(screen.getByRole('button', { name: zh['section.clear'] }))
expect(screen.queryByText('settled work')).toBeNull()
expect(screen.queryByRole('button', { name: zh['section.clear'] })).toBeNull()
})
it('opens the settled tail by default when nothing is live', () => {
render(<JobListAction {...props([
job({ status: 'completed', finishedAt: 1_700_000_012_000 }),
])} />)
openList()
expect(screen.getByText('pnpm run build')).toBeDefined()
expect(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }).getAttribute('aria-expanded')).toBe('true')
})
it('clearing drops an expanded settled panel with its rows', () => {
const observe = vi.fn(() => () => {})
render(<JobListAction {...props(
[outputJob({ id: 'bash-2' as JobView['id'], label: 'settled work', status: 'completed', finishedAt: 1_700_000_012_000 })],
observe,
)} />)
openList()
// Nothing live: the tail is open; expand the settled row's output panel.
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'settled work') }))
expect(observe).toHaveBeenCalledWith(SESSION, 'bash-2')
fireEvent.click(screen.getByRole('button', { name: zh['section.clear'] }))
expect(screen.queryByText('settled work')).toBeNull()
expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'settled work') })).toBeNull()
})
it('shows a settled duration and ticks a live one', () => {
@@ -130,6 +159,7 @@ describe('JobListAction rows', () => {
job({ id: 'bash-4' as JobView['id'], status: 'completed', startedAt: 1_699_999_900_000, finishedAt: 1_699_999_972_000 }),
])} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '3') }))
expect(screen.getByText('5秒')).toBeDefined()
expect(screen.getByText('12秒')).toBeDefined()
expect(screen.getByText('1小时3分')).toBeDefined()
@@ -138,18 +168,22 @@ describe('JobListAction rows', () => {
expect(screen.getByText('6秒')).toBeDefined()
})
it('lists rows with kind, label, and progress, terminal detail, or status', () => {
it('lists rows with kind, label, and detail-or-status', () => {
render(<JobListAction {...props([
job(),
outputJob(),
outputJob({ id: 'bash-2' as JobView['id'], status: 'failed', detail: 'exit code: 3', finishedAt: 1_700_000_002_000 }),
])} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '1') }))
const list = screen.getByRole('list', { name: zh['list.aria'] })
const items = within(list).getAllByRole('listitem')
expect(items).toHaveLength(2)
// The section line is a listitem too now: live row, section, settled row.
expect(items).toHaveLength(3)
expect(items[0]?.textContent).toContain('pnpm run build')
expect(items[0]?.textContent).toContain(zh['status.running'])
expect(items[1]?.textContent).toContain('exit code: 3')
// A live row's second line is kind · duration; the status word only
// appears through a detail (none while running).
expect(items[0]?.textContent).toMatch(/小时|分|秒/)
expect(items[2]?.textContent).toContain('exit code: 3')
})
it('orders live rows first by start and settled rows newest-first with tie-breaks', () => {
@@ -171,9 +205,11 @@ describe('JobListAction rows', () => {
job({ id: 'bash-l2' as JobView['id'], label: 'live-2', startedAt: 3 }),
])} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '5') }))
const labels = within(screen.getByRole('list', { name: zh['list.aria'] }))
.getAllByRole('listitem')
.map(item => item.querySelector('[title]')?.getAttribute('title'))
.filter((title): title is string => title != null && !title.startsWith('已运行') && !title.startsWith('耗时'))
expect(labels).toEqual([
'live-2', 'live-1', 'settled-d', 'settled-a', 'settled-c', 'settled-b', 'settled-e',
])
@@ -186,7 +222,7 @@ describe('JobListAction rows', () => {
job({ id: 'bash-1' as JobView['id'], label: 'first', status: 'completed', startedAt: 1_700_000_000_000, finishedAt: 1_700_000_000_000 + 100 }),
])} />)
openList()
expect(within(screen.getByRole('list')).getAllByRole('listitem').map(row => row.querySelector('[title]')?.getAttribute('title')))
expect(within(screen.getByRole('list')).getAllByRole('listitem').map(row => row.querySelector('[title]')?.getAttribute('title')).filter(title => title !== undefined))
.toEqual(['first', 'second'])
})
@@ -195,10 +231,10 @@ describe('JobListAction rows', () => {
job({ status: 'killed', detail: 'signal: SIGTERM', finishedAt: 1_700_000_000_000 + 2_000 }),
])} />)
openList()
expect(within(screen.getByRole('list')).getAllByRole('listitem')[0]?.textContent).toContain('signal: SIGTERM')
expect(within(screen.getByRole('list')).getByText('signal: SIGTERM')).toBeDefined()
})
it('renders every status word, including the stopping transition', () => {
it('renders settled status words and every lifecycle indicator', () => {
render(<JobListAction {...props([
job({ id: 'bash-1' as JobView['id'], label: 'a', status: 'running' }),
job({ id: 'bash-2' as JobView['id'], label: 'b', status: 'stopping' }),
@@ -207,7 +243,8 @@ describe('JobListAction rows', () => {
job({ id: 'bash-5' as JobView['id'], label: 'e', status: 'failed', finishedAt: 1_700_000_000_000 }),
])} />)
openList()
for (const word of ['运行中', '正在停止', '已完成', '已取消', '已失败']) {
fireEvent.click(screen.getByRole('button', { name: zh['section.settledCount'].replace('{count}', '3') }))
for (const word of ['已完成', '已取消', '已失败']) {
expect(within(screen.getByRole('list')).getByText(word)).toBeDefined()
}
expect([...screen.getByRole('list').querySelectorAll('li [data-state]')].map(node => node.getAttribute('data-state')))
@@ -251,7 +288,7 @@ describe('JobListAction observation', () => {
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true })
try {
render(<JobListAction {...props([job()])} />)
render(<JobListAction {...props([outputJob()])} />)
openList()
const menu = screen.getByRole('list', { name: zh['list.aria'] })
// 700 - 12 - 440 - 344 = -96: the popover moves left to keep the margin.
@@ -272,7 +309,7 @@ describe('JobListAction observation', () => {
const originalWidth = window.innerWidth
Object.defineProperty(window, 'innerWidth', { value: 700, configurable: true, writable: true })
try {
render(<JobListAction {...props([job()])} />)
render(<JobListAction {...props([outputJob()])} />)
openList()
const menu = screen.getByRole('list', { name: zh['list.aria'] })
// max(12 - 4, min(0, 700 - 12 - 800 - 4)) = 8: clamped at the left margin.
@@ -307,7 +344,7 @@ describe('JobListAction observation', () => {
gapBefore: false,
streaming: true,
}
render(<JobListAction {...props([job()], observe, { 'bash-1': view })} />)
render(<JobListAction {...props([outputJob()], observe, { 'bash-1': view })} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
expect(observe).toHaveBeenCalledWith(SESSION, 'bash-1')
@@ -339,7 +376,7 @@ describe('JobListAction observation', () => {
streaming: false,
error: 'connection lost',
}
render(<JobListAction {...props([job()], undefined, { 'bash-1': view })} />)
render(<JobListAction {...props([outputJob()], undefined, { 'bash-1': view })} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
expect(screen.getByText(zh['output.gap'])).toBeDefined()
@@ -348,7 +385,7 @@ describe('JobListAction observation', () => {
it('stops observation when the popover closes via Escape', () => {
const stop = vi.fn()
render(<JobListAction {...props([job()], () => stop)} />)
render(<JobListAction {...props([outputJob()], () => stop)} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
fireEvent.keyDown(screen.getByRole('list', { name: zh['list.aria'] }), { key: 'Escape' })
@@ -360,11 +397,122 @@ describe('JobListAction observation', () => {
const stop = vi.fn()
// One stable observe identity across renders, as the inject face provides.
const observe = () => stop
const { rerender } = render(<JobListAction {...props([job()], observe)} />)
const { rerender } = render(<JobListAction {...props([outputJob()], observe)} />)
openList()
fireEvent.click(screen.getByRole('button', { name: zh['row.expandAria'].replace('{label}', 'pnpm run build') }))
rerender(<JobListAction {...props([job({ id: 'bash-2' as JobView['id'], label: 'other' })], observe)} />)
rerender(<JobListAction {...props([outputJob({ id: 'bash-2' as JobView['id'], label: 'other' })], observe)} />)
expect(stop).toHaveBeenCalledTimes(1)
expect(screen.queryByRole('button', { name: zh['row.collapseAria'].replace('{label}', 'other') })).toBeNull()
})
})
describe('JobListAction human kill', () => {
const stopTitle = (label: string): string => zh['kill.stop'].replace('{label}', label)
it('offers the stop control only on running rows', () => {
render(<JobListAction {...props([
job(),
job({ id: 'bash-2' as JobView['id'], label: 'done', status: 'completed', finishedAt: 1_700_000_100_000 }),
])} />)
openList()
expect(screen.getByTitle(stopTitle('pnpm run build'))).toBeDefined()
expect(screen.queryByTitle(stopTitle('done'))).toBeNull()
})
it('arms on the first press and kills on the confirming press', async () => {
const killJob = vi.fn(async () => true)
render(<JobListAction {...props([job()], undefined, {}, killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(killJob).not.toHaveBeenCalled()
expect(stop.getAttribute('data-kill-state')).toBe('armed')
expect(screen.getByTitle(zh['kill.confirm'])).toBe(stop)
// The armed step is legible without hover: the button carries the label.
expect(stop.textContent).toBe(zh['kill.confirmAction'])
await act(async () => { fireEvent.click(stop) })
expect(killJob).toHaveBeenCalledWith(SESSION, 'bash-1')
// An admitted kill stays pending: the unary response and the jobs frames
// have no cross-carrier ordering, so only the authoritative frame (the row
// leaving the killable set) releases the control — never the response.
expect(stop.getAttribute('data-kill-state')).toBe('pending')
expect(stop.hasAttribute('disabled')).toBe(true)
})
it('an armed press disarms after the confirmation window', () => {
vi.useFakeTimers()
const killJob = vi.fn(async () => true)
render(<JobListAction {...props([job()], undefined, {}, killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(stop.getAttribute('data-kill-state')).toBe('armed')
act(() => { vi.advanceTimersByTime(3_000) })
expect(stop.getAttribute('data-kill-state')).toBe('idle')
expect(killJob).not.toHaveBeenCalled()
})
it('a rejected kill shows its hint and then resets', async () => {
// Fake timers from the start so the failed-hint reset arms on the fake clock.
vi.useFakeTimers()
const killJob = vi.fn(async () => false)
render(<JobListAction {...props([job()], undefined, {}, killJob)} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
await act(async () => { fireEvent.click(stop) })
expect(stop.getAttribute('data-kill-state')).toBe('failed')
expect(screen.getByTitle(zh['kill.failed'])).toBe(stop)
act(() => { vi.advanceTimersByTime(4_000) })
expect(stop.getAttribute('data-kill-state')).toBe('idle')
})
it('arming a second row disarms the first', () => {
render(<JobListAction {...props([
job(),
job({ id: 'bash-2' as JobView['id'], label: 'pnpm run watch' }),
])} />)
openList()
const first = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(first)
expect(first.getAttribute('data-kill-state')).toBe('armed')
const second = screen.getByTitle(stopTitle('pnpm run watch'))
fireEvent.click(second)
expect(second.getAttribute('data-kill-state')).toBe('armed')
expect(first.getAttribute('data-kill-state')).toBe('idle')
})
it('a kill resolving after another row armed leaves the newer phase alone', async () => {
let resolveKill!: (ok: boolean) => void
const killJob = vi.fn(() => new Promise<boolean>((resolve) => { resolveKill = resolve }))
render(<JobListAction {...props([
job(),
job({ id: 'bash-2' as JobView['id'], label: 'pnpm run watch' }),
], undefined, {}, killJob)} />)
openList()
const first = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(first)
fireEvent.click(first)
expect(killJob).toHaveBeenCalledTimes(1)
// While the first kill is in flight, the user arms the second row; the
// late resolution must not clobber that newer phase.
const second = screen.getByTitle(stopTitle('pnpm run watch'))
fireEvent.click(second)
expect(second.getAttribute('data-kill-state')).toBe('armed')
await act(async () => { resolveKill(false) })
expect(second.getAttribute('data-kill-state')).toBe('armed')
expect(first.getAttribute('data-kill-state')).toBe('idle')
})
it('clears an armed phase whose row stopped being killable', () => {
const { rerender } = render(<JobListAction {...props([job()])} />)
openList()
const stop = screen.getByTitle(stopTitle('pnpm run build'))
fireEvent.click(stop)
expect(stop.getAttribute('data-kill-state')).toBe('armed')
rerender(<JobListAction {...props([job({ status: 'stopping' })])} />)
// The stopping row offers no kill control any more, and the stale phase is gone.
expect(screen.queryByTitle(stopTitle('pnpm run build'))).toBeNull()
expect(document.querySelector('[data-kill-state]')).toBeNull()
})
})
@@ -62,19 +62,19 @@
/* The unavailable preview emphasizes the same actions with a labeled button. */
.split[data-size='large'] {
height: 40px;
height: 36px;
border-radius: 14px;
border-color: var(--dsw-alias-border-l2);
}
.split[data-size='large'] .main {
gap: 6px;
padding: 8px 14px;
padding: 6px 14px;
font-size: 14px;
}
.split[data-size='large'] .chevron {
padding: 8px 10px;
padding: 6px 10px;
}
.skeleton { display: inline-block; flex: none; border-radius: 3px; background: var(--dsw-alias-interactive-bg-hover); }
@@ -133,7 +133,7 @@ export function OpenTargetButton(props: OpenTargetButtonProps): ReactNode {
setMenuOpen(value => !value)
}}
>
<IconChevronDownOutlineRegular size={10} />
<IconChevronDownOutlineRegular size={props.prominent ? 14 : 10} />
</button>}
</div>
)}
@@ -256,6 +256,8 @@ it('uses the same application menu in the prominent empty-state control', async
const view = render(<OpenPathEmptyAction {...b.props} />)
await act(async () => {})
expect(view.container.querySelector('[data-open-target]')?.getAttribute('data-size')).toBe('large')
// The large control scales the menu chevron up from the compact 10px glyph.
expect(screen.getByRole('button', { name: zh['path.more'] }).querySelector('svg')?.getAttribute('width')).toBe('14')
await act(async () => { fireEvent.click(screen.getByRole('button', { name: zh['path.more'] })) })
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['Music(默认)', zh['path.reveal']])
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Music(默认)' })) })
@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-client-ui-sidebar-documentpreview",
"description": "Extensible document previews for Sidebar files: Office, Markdown, highlighted code, images, PDF, HTML, and plain text",
"description": "Extensible Sidebar previews for Office documents, spreadsheets, Markdown, code, images, PDF, HTML, and plain text",
"version": "0.1.6-alpha.2",
"publishConfig": {
"access": "public"
@@ -49,16 +49,3 @@
padding: 16px;
color: var(--dsw-alias-label-secondary);
}
.loadingIcon {
display: flex;
animation: turn 0.8s linear infinite;
}
@keyframes turn {
to { transform: rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
.loadingIcon { animation: none; }
}
@@ -1,7 +1,7 @@
/** Read-only spreadsheet surface backed by browser-parsed workbook data. */
import { useEffect, useState, type ReactNode } from 'react'
import { Workbook } from '@fortune-sheet/react'
import { Button, IconLoadingOutlineRegular, IconWarningTriangleOutlineRegular, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import { Button, IconWarningTriangleOutlineRegular, StateDot, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import fortuneCss from '@fortune-sheet/react/dist/index.css?inline'
import type { ExcelFormat } from './format.ts'
import type { ExcelPreview } from './model.ts'
@@ -33,7 +33,7 @@ export function ExcelBody({ content, format, limits, t }: LoadedExcelBodyProps):
}, [data, format, limits, attempt])
if (data === undefined) return <p className={css.status} role="alert">{t('invalid')}</p>
if (state?.data !== data || state.format !== format) return <span className={css.status} role="status" aria-label={t('loading')} data-document-loading>
<span className={css.loadingIcon} aria-hidden="true"><IconLoadingOutlineRegular /></span>
<StateDot state="ongoing" />
</span>
if ('error' in state) return <div className={css.status} role="alert">
<span>{t(state.error === 'tooLarge' || state.error === 'timeout' || state.error === 'encoding' ? state.error : 'invalid')}</span>
@@ -21,20 +21,28 @@ const THEME_ORDER = ['lt1', 'dk1', 'lt2', 'dk2', 'accent1', 'accent2', 'accent3'
export async function convertXlsx(bytes: Uint8Array<ArrayBuffer>, limits: ExcelLimits): Promise<ExcelPreview> {
const workbook = new ExcelJS.Workbook()
try {
await workbook.xlsx.load(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength))
const input = bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength
? bytes.buffer : bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
await workbook.xlsx.load(input)
} catch (error) {
throw new ExcelPreviewError('invalid', { cause: error })
}
if (!workbook.worksheets.some(sheet => sheet.state === 'visible')) throw new ExcelPreviewError('invalid')
let area = 0
for (const sheet of workbook.worksheets) {
area += Math.max(1, sheet.rowCount) * Math.max(1, sheet.columnCount)
if (area > limits.maxCells) throw new ExcelPreviewError('tooLarge')
}
const colors = themeColors(workbook)
let missingResults = 0
let active = false
const sheets = workbook.worksheets.map((worksheet, order): Sheet => {
let row = 1
let column = 1
const include = (r: number, c: number): void => {
row = Math.max(row, r + 1)
column = Math.max(column, c + 1)
const sheetArea = row * column
if (!Number.isSafeInteger(sheetArea) || !Number.isSafeInteger(area + sheetArea) || area + sheetArea > limits.maxCells) {
throw new ExcelPreviewError('tooLarge')
}
}
const config: Required<Pick<SheetConfig, 'merge' | 'rowlen' | 'columnlen' | 'rowhidden' | 'colhidden' | 'borderInfo'>> = {
merge: {}, rowlen: {}, columnlen: {}, rowhidden: {}, colhidden: {}, borderInfo: [],
}
@@ -48,6 +56,7 @@ export async function convertXlsx(bytes: Uint8Array<ArrayBuffer>, limits: ExcelL
r: Number(first.row) - 1, c: Number(first.col) - 1,
rs: Number(last.row) - Number(first.row) + 1, cs: Number(last.col) - Number(first.col) + 1,
}
include(merge.r + merge.rs - 1, merge.c + merge.cs - 1)
config.merge[`${merge.r}_${merge.c}`] = merge
}
// ExcelJS's declarations omit absent parsed collections and style fields.
@@ -63,6 +72,7 @@ export async function convertXlsx(bytes: Uint8Array<ArrayBuffer>, limits: ExcelL
if (row.hidden) config.rowhidden[r] = 0
row.eachCell({ includeEmpty: true }, (cell) => {
const c = Number(cell.col) - 1
include(r, c)
const v = convertCell(cell, colors)
if (v.f !== undefined && v.v === undefined) missingResults += 1
if (cell.isMerged) {
@@ -81,13 +91,18 @@ export async function convertXlsx(bytes: Uint8Array<ArrayBuffer>, limits: ExcelL
}
})
})
config.rowlen = withinBounds(config.rowlen, row)
config.rowhidden = withinBounds(config.rowhidden, row)
config.columnlen = withinBounds(config.columnlen, column)
config.colhidden = withinBounds(config.colhidden, column)
area += row * column
const view = (worksheet.views as ExcelJS.WorksheetView[] | null)?.[0]
const visible = worksheet.state === 'visible'
const status = visible && !active ? 1 : 0
active ||= visible
const sheet: Sheet = {
id: String(worksheet.id), name: worksheet.name, order, status, hide: visible ? 0 : 1,
row: Math.max(1, worksheet.rowCount), column: Math.max(1, worksheet.columnCount),
row, column,
config, celldata, showGridLines: view?.showGridLines !== false,
defaultRowHeight: worksheet.properties.defaultRowHeight * 96 / 72,
luckysheet_select_save: initialSelection(config),
@@ -121,27 +136,62 @@ function convertCell(
else if (alignment.textRotation !== undefined) result.rt = alignment.textRotation
}
const value = cell.value
let display: ExcelJS.CellValue = value
let display: unknown = value
if (value !== null && typeof value === 'object' && ('formula' in value || 'sharedFormula' in value)) {
result.f = `=${cell.formula}`
display = value.result ?? null
// ExcelJS omits falsy caches from cell.value, while cell.result retains 0 and false.
const cached: unknown = cell.result
display = cached
}
if (display instanceof Date) {
const richText = richTextOf(display)
if (richText !== undefined) {
setRichText(result, richText, colors)
} else if (display instanceof Date) {
// FortuneSheet uses the 1900 date system; ExcelJS has already resolved the workbook epoch.
result.v = display.getTime() / 86_400_000 + 25569
} else if (display !== null && typeof display === 'object') {
if ('richText' in display) {
result.ct = { t: 'inlineStr', s: display.richText.map(run => ({ v: run.text, ...fontStyle(run.font, colors) })) }
result.v = display.richText.map(run => run.text).join('')
} else if ('text' in display) result.v = display.text
else result.v = (display as ExcelJS.CellErrorValue).error
} else if (display !== null && display !== undefined) result.v = display
const text: unknown = Reflect.get(display, 'text')
const hyperlinkRichText = richTextOf(text)
if (hyperlinkRichText !== undefined) setRichText(result, hyperlinkRichText, colors)
else if (typeof text === 'string') result.v = text
else result.v = String(Reflect.get(display, 'error'))
} else if (typeof display === 'string' || typeof display === 'number' || typeof display === 'boolean') result.v = display
return formatCell(result, cell.numFmt || 'General')
}
type RichTextRun = { text: string; font?: Partial<Font> | undefined }
function richTextOf(value: unknown): RichTextRun[] | undefined {
if (typeof value !== 'object' || value === null) return undefined
const source: unknown = Reflect.get(value, 'richText')
if (!Array.isArray(source)) return undefined
return source.map((item): RichTextRun => {
const run = record(item)
const font = run.font
return { text: String(run.text), font: typeof font === 'object' && font !== null ? font : undefined }
})
}
function setRichText(result: Cell, runs: readonly RichTextRun[], colors: readonly (string | undefined)[]): void {
result.ct = { t: 'inlineStr', s: runs.map(run => ({ v: run.text, ...fontStyle(run.font, colors) })) }
result.v = runs.map(run => run.text).join('')
}
function withinBounds(values: Record<number, number>, length: number): Record<number, number> {
const result: Record<number, number> = {}
for (const [key, value] of Object.entries(values)) {
const index = Number(key)
if (index < length) result[index] = value
}
return result
}
function fontStyle(font: Partial<Font> | undefined, colors: readonly (string | undefined)[]): CellStyle {
if (font === undefined) return {}
const result: CellStyle = { bl: font.bold ? 1 : 0, it: font.italic ? 1 : 0, cl: font.strike ? 1 : 0, un: font.underline ? 1 : 0 }
const result: CellStyle = {
bl: font.bold ? 1 : 0, it: font.italic ? 1 : 0, cl: font.strike ? 1 : 0,
un: font.underline !== undefined && font.underline !== false && font.underline !== 'none' ? 1 : 0,
}
if (font.name !== undefined) result.ff = font.name
if (font.size !== undefined) result.fs = font.size
const fc = colorOf(font.color, colors)
@@ -1,3 +1,4 @@
/** Published PDF and spreadsheet chunks retain their bundled license notices. */
import { spawnSync } from 'node:child_process'
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -34,9 +34,12 @@ describe('Excel conversion', () => {
expect(secondCell(0, 2)).toMatchObject({ v: '富文本 示例', ct: { t: 'inlineStr' } })
expect(secondCell(0, 2).ct?.s).toBeInstanceOf(Array)
expect(secondCell(0, 3)).toMatchObject({ v: 'Link text' })
expect(secondCell(1, 3)).toMatchObject({ v: 'Rich link', ct: { t: 'inlineStr' } })
expect(secondCell(2, 0)).toMatchObject({ f: '=SUM(1,2)', v: 3 })
expect(secondCell(3, 0)).toMatchObject({ v: true })
expect(secondCell(4, 0)).toMatchObject({ v: '#DIV/0!' })
expect(secondCell(0, 5)).toMatchObject({ f: '=1-1', v: 0, m: '0' })
expect(secondCell(1, 5)).toMatchObject({ f: '=1=2', v: false, m: 'false' })
expect(second.config).toMatchObject({ rowhidden: { 6: 0 }, colhidden: { 4: 0 }, rowlen: { 6: 40 * 96 / 72 } })
expect(sheets[2]).toMatchObject({ hide: 1, status: 0 })
expect(missingResults).toBe(1)
@@ -57,9 +60,16 @@ describe('Excel conversion', () => {
await expect(convertExcel(new Uint8Array([1, 2, 3]), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
const input = await excelFixture()
await expect(convertExcel(input, 'xlsx', { ...limits, maxBytes: input.byteLength - 1 })).rejects.toMatchObject({ code: 'tooLarge' })
const framed = new Uint8Array(input.byteLength + 2)
framed.set(input, 1)
await expect(convertExcel(framed.subarray(1, -1), 'xlsx', limits)).resolves.toMatchObject({ missingResults: 1 })
const workbook = new ExcelJS.Workbook()
workbook.addWorksheet('Sparse').getCell('Z1000').value = 1
await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', { ...limits, maxCells: 25_000 })).rejects.toMatchObject({ code: 'tooLarge' })
const styled = new ExcelJS.Workbook()
styled.addWorksheet('Styled').getCell('A1').value = 1
styled.getWorksheet('Styled')!.getCell('XFD1000').font = { bold: true }
await expect(convertExcel(new Uint8Array(await styled.xlsx.writeBuffer()), 'xlsx', limits)).rejects.toMatchObject({ code: 'tooLarge' })
})
it('opens an empty worksheet and rejects workbooks with no visible sheet', async () => {
@@ -71,6 +81,18 @@ describe('Excel conversion', () => {
await expect(convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits)).rejects.toMatchObject({ code: 'invalid' })
})
it('ignores row and column layout outside emitted cell bounds', async () => {
const workbook = new ExcelJS.Workbook()
const sheet = workbook.addWorksheet('Layout')
for (let row = 1; row <= 30; row += 1) sheet.getCell(row, 1).value = row
sheet.getColumn(16_384).width = 20
sheet.getRow(1000).height = 40
const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits)
expect(result.sheets[0]).toMatchObject({ row: 30, column: 1 })
expect(result.sheets[0]!.config!.columnlen).not.toHaveProperty('16383')
expect(result.sheets[0]!.config!.rowlen).not.toHaveProperty('999')
})
it.each([false, true])('retains dates for the 1904 epoch flag %s', async (date1904) => {
const workbook = new ExcelJS.Workbook()
workbook.properties.date1904 = date1904
@@ -103,6 +125,8 @@ describe('Excel conversion', () => {
sheet.getCell('E1').font = { color: { argb: 'FFFF0000', tint: 0 } as Partial<ExcelJS.Color> }
sheet.getCell('F1').value = 42
sheet.getCell('F1').alignment = { vertical: 'middle' }
sheet.getCell('G1').value = 'No underline'
sheet.getCell('G1').font = { underline: 'none' }
const result = await convertExcel(new Uint8Array(await workbook.xlsx.writeBuffer()), 'xlsx', limits)
expect(result.sheets[0]).toMatchObject({ defaultColWidth: 89, config: { columnlen: { 0: 6 } } })
const cells = result.sheets[0]!.celldata!
@@ -112,6 +136,7 @@ describe('Excel conversion', () => {
expect(cells[3]!.v).not.toHaveProperty('fc')
expect(cells[4]!.v).toMatchObject({ fc: '#FF0000' })
expect(cells[5]!.v).toMatchObject({ v: 42, ht: 2, vt: 0, ct: { t: 'n' } })
expect(cells[6]!.v).toMatchObject({ v: 'No underline', un: 0 })
})
it.each([{ ySplit: 2 }, { xSplit: 1 }, {}])('maps one-axis or empty freeze settings %j', async (freeze) => {
@@ -38,9 +38,14 @@ export async function excelFixture(): Promise<Uint8Array<ArrayBuffer>> {
details.getCell('B1').numFmt = 'yyyy-mm-dd'
details.getCell('C1').value = { richText: [{ text: '富文本', font: { bold: true } }, { text: ' 示例', font: { italic: true } }] }
details.getCell('D1').value = { text: 'Link text', hyperlink: 'https://example.com' }
// ExcelJS's reader permits rich text here even though its hyperlink declaration requires a string.
details.getCell('D2').value = { text: { richText: [{ text: 'Rich', font: { bold: true } }, { text: ' link' }] }, hyperlink: 'https://example.com' } as never
details.getCell('A3').value = { sharedFormula: 'A2', result: 3 }
details.getCell('A4').value = true
details.getCell('A5').value = { error: '#DIV/0!' }
details.getCell('F1').value = { formula: '1-1', result: 0 }
details.getCell('F2').value = { formula: '1=2', result: false }
details.getCell('A7').value = 'Hidden row'
details.getRow(7).height = 40
details.getRow(7).hidden = true
details.getColumn(5).hidden = true
+1 -1
View File
@@ -134,7 +134,7 @@ async function queryTmuxLocation(
].join('\n')
let result: ShellRunResult
try {
result = await bash.run(bash.resolve({ command, signal }))
result = await (await bash.execute(bash.resolve({ command, signal }))).result()
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error)
logger.warn(`tmux location query failed: ${message}; injecting no location this turn`)
@@ -5,11 +5,14 @@ import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import { createUserMessage } from '@deepseek-ai/dsh-llm'
import { ShellExecutor } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellProcess, ShellRunResult } from '@deepseek-ai/dsh-shell'
import type { ShellExecRequest, ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
import * as tmuxContext from '@deepseek-ai/dsh-tmux-context'
import type { Config } from '@deepseek-ai/dsh-tmux-context'
import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit'
/** Empty offset readers for fakes that never produce output. */
const silentReader = { readFrom: (fromByte: number) => ({ text: '', nextOffset: fromByte, lossy: false }) }
const SIGNAL = new AbortController().signal
/** One `#{...}`-joined tmux reading line for the eight queried fields. */
@@ -61,18 +64,25 @@ class FakeBash extends ShellExecutor {
command: request.command,
workdir: request.workdir ?? '/work',
timeoutMs: request.timeoutMs ?? 60_000,
onExpiry: request.onExpiry ?? 'kill',
stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000,
signal: request.signal,
sandboxPolicy: request.sandboxPolicy,
}
}
override async run(spec: ShellExecSpec): Promise<ShellRunResult> {
override async execute(spec: ShellExecSpec): Promise<ShellExecution> {
if (spec.onExpiry === 'none') throw new Error('tmux-context must never start a background job')
this.commands.push(spec.command)
if (this.runError) throw this.runError
return this.result
}
override async start(): Promise<ShellProcess> {
throw new Error('tmux-context must never start a background job')
return {
status: 'completed',
exitCode: 0,
signal: null,
done: Promise.resolve(),
readOutput: () => ({ delta: '', lossy: false }),
observed: { stdout: silentReader, stderr: silentReader },
kill: () => false,
result: () => this.runError ? Promise.reject(this.runError) : Promise.resolve(this.result),
}
}
}
@@ -9,6 +9,13 @@ import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import { MemoryVfs } from '../../src/storage/memory.ts'
import { setActiveVfs } from '../../src/storage/active.ts'
import { processAlive, signalProcess } from '../../src/node/process-table.ts'
import type { ShellExecSpec, ShellExecution, ShellRunResult } from '@deepseek-ai/dsh-shell'
/** Historical foreground shorthand over the unified execute() seam. */
async function run(x: { execute(spec: ShellExecSpec): Promise<ShellExecution> }, spec: ShellExecSpec): Promise<ShellRunResult> {
return (await x.execute(spec)).result()
}
vi.mock('node:child_process', async () => await import('../../src/node/builtin_modules/implemented/child_process.ts'))
@@ -55,14 +62,14 @@ async function setup(mode: 'read-only' | 'workspace-write' | 'danger-full-access
describe('Worker Landlock through the production sandbox stack', () => {
it('allows workspace and temp writes while classifying an outside write as denied', async () => {
const bash = await setup('workspace-write')
const allowed = await bash.run(bash.resolve({
const allowed = await run(bash, bash.resolve({
command: `echo workspace > ${WORKSPACE}/allowed.txt; echo temp > /tmp/allowed.txt`,
}))
expect(allowed.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' })
expect(vfs.readFileSync(`${WORKSPACE}/allowed.txt`, 'utf8')).toBe('workspace\n')
expect(vfs.readFileSync('/dsh/tmp/allowed.txt', 'utf8')).toBe('temp\n')
const denied = await bash.run(bash.resolve({ command: `echo denied > ${OUTSIDE}/denied.txt` }))
const denied = await run(bash, bash.resolve({ command: `echo denied > ${OUTSIDE}/denied.txt` }))
expect(denied.exitCode).toBe(1)
expect(denied.sandbox).toEqual({ mode: 'workspace-write', denied: true, enforcement: 'full' })
expect(vfs.existsSync(`${OUTSIDE}/denied.txt`)).toBe(false)
@@ -70,24 +77,24 @@ describe('Worker Landlock through the production sandbox stack', () => {
it('keeps read-only confined and danger-full-access unwrapped', async () => {
const readOnly = await setup('read-only')
const strict = await readOnly.run(readOnly.resolve({
const strict = await run(readOnly, readOnly.resolve({
command: `echo discarded > /dev/null; echo denied > ${WORKSPACE}/strict.txt`,
}))
expect(strict.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' })
expect(vfs.existsSync(`${WORKSPACE}/strict.txt`)).toBe(false)
const unrestricted = await setup('danger-full-access')
const result = await unrestricted.run(unrestricted.resolve({ command: `echo allowed > ${OUTSIDE}/full.txt` }))
const result = await run(unrestricted, unrestricted.resolve({ command: `echo allowed > ${OUTSIDE}/full.txt` }))
expect(result.sandbox).toEqual({ mode: 'danger-full-access', denied: false })
expect(vfs.readFileSync(`${OUTSIDE}/full.txt`, 'utf8')).toBe('allowed\n')
})
it('does not leak a concurrent command policy into another process', async () => {
const bash = await setup('read-only')
const strict = bash.run(bash.resolve({
const strict = run(bash, bash.resolve({
command: `sleep 0.02; echo denied > ${WORKSPACE}/strict.txt`,
}))
const writable = bash.run(bash.resolve({
const writable = run(bash, bash.resolve({
command: `echo allowed > ${WORKSPACE}/writable.txt`,
sandboxPolicy: { mode: 'workspace-write', workspaceRoot: WORKSPACE },
}))
@@ -1178,12 +1178,18 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
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.',
},
{
signature: '@Remote(\'kill\') kill(request: JobKillRequest): JobKillValue',
description: 'Kill one background job on a human\'s behalf. The request\'s session is the fenced read\'s caller, so the job must be one that session can see: the registry\'s owner fence is the only access rule, and a child session\'s own jobs are killable from its list like any other. The kill records `cancelled by the user` as its reason; it is not one the model requested, so the owning agent still receives the completion notice, and a shell tool waiting on that job reads the reason in its own result.',
parameters: [{ name: 'request', description: 'Session whose job list carries the job, and the job id.' }],
returns: 'the registry\'s admission of the kill request.',
},
],
},
{
key: 'jobs',
summary: 'Abstract background job registry.',
description: 'Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: \'teardown\'`, because a job whose owner is being destroyed has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter, so a consumer that claims a settlement while waiting always claims before the event.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: \'scope\' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model\'s consuming cursor and observers\' absolute offsets read the same bytes and never disturb each other.\n- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.',
description: 'Abstract background job registry. Subclass, implement the abstract members, and load the subclass as a plugin — it registers as `ctx.jobs` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- Registrations outlive producer and controller fibers. Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. Such settlements announce `cause: \'teardown\'`, because a job whose owner is being destroyed has no reader left.\n- Owned-job access is fenced by the owner\'s session id. Ids are predictable, so authorization — not secrecy — is the boundary.\n- Settlement is first-wins: one terminal record, released waiters, then one round of contained event delivery, even against a late producer outcome. The `settled` event follows every released waiter and reports whether it released one (`awaited`), so a completion reporter can skip settlements a waiting caller already collected.\n- A settled record stays listed until its owner\'s disposal, service disposal, or an explicit remove by a caller that collected the terminal state itself and never handed the id out.\n- start refuses work while no attached job controller serves the spec\'s owner, so a producer cannot start work that owner cannot collect or stop. One registry serves every composition in the process, so this question — and event delivery under `{ owners: \'scope\' }` — is owner-relative rather than process-wide: registrations made from an unscoped context serve every owner, and registrations made under an agent composition\'s scope serve exactly the agents composed under it.\n- Every job owns one output ring. Pull sources named by the spec are pumped by the registry and drained once more before settlement; pushed appends land whole. The model\'s consuming cursor and observers\' absolute offsets read the same bytes and never disturb each other.\n- Ring retention is bounded. Appends past the live cap drop the oldest retained bytes; a reader below the retained window gets a lossy read, never an error. Settlement trims retention to the settled cap and ends the stream; the ring has no separate lifecycle.',
methods: [
{
signature: 'abstract readonly events: JobEvents',
@@ -1232,6 +1238,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
parameters: [{ name: 'id', description: 'job to wait for.' }, { name: 'timeoutMs', description: 'positive finite wait bound in milliseconds.' }, { name: 'caller', description: 'waiting session checked against the owner.' }, { name: 'signal', description: 'optional cancellation of the wait itself.' }],
returns: 'projection at settlement or timeout.',
},
{
signature: 'abstract remove(id: JobId, caller?: SessionId): void',
description: 'Drop one settled job\'s record from the visible set and announce `removed`. For a caller that collected the terminal state through its own wait and never handed the id to the model, such as a shell tool\'s foreground call. Throws for a job that is still live, unknown, or foreign.',
parameters: [{ name: 'id', description: 'settled job to drop.' }, { name: 'caller', description: 'removing session checked against the owner.' }],
},
{
signature: 'abstract attachController(name: string): () => void',
description: 'Attach an effect-scoped controller that can read and stop jobs. It serves the owners its registering context\'s scope covers, and start refuses an owner no attached controller serves.',
@@ -2344,27 +2355,21 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
{
key: 'shell',
summary: 'Abstract bash execution service.',
description: 'Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nImplementations must honor these semantics:\n\n- run rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a ShellRunResult.\n- start resolves after launch preparation; cancellation or setup failure rejects before publishing a handle. No timeout applies to background processes. Once published, `done` settles at process close and never rejects; subprocess provider failures settle as `killed` with the error on stderr.\n- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.\n- A still-running background process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a background process survives an executor-only reload.',
description: 'Abstract bash execution service. Subclass, implement the abstract methods, and load the subclass as a plugin — it registers as `ctx.shell` (one implementation per context; loading a second throws, which is cordis\' standard duplicate-service behavior).\n\nexecute resolves with the process handle after preparation. "Foreground" is a property of what the caller awaits, not of the spawn — a caller that awaits ShellExecution.result ran the command in the foreground; one that keeps the handle ran it in the background. A caller that waits only for a while runs the command under `onExpiry: \'none\'` and bounds its own wait; the handle stays valid after the caller stops waiting.\n\nImplementations must honor these semantics:\n\n- ShellExecution.result rejects only for infrastructure failures. Nonzero exits, timeout kills, and abort kills resolve with a descriptive result: first-cause `timedOut`/`aborted`, the spec\'s `timeoutMs` echoed.\n- The handle is published after preparation. `done` settles at process close and never rejects; spawn failures settle as `killed` with the error on the read path, while `result()` carries the same failure as its rejection.\n- `onExpiry: \'none\'` arms no deadline; `\'kill\'` kills at expiry. Expiry during preparation returns a settled timed-out handle without output.\n- ShellProcess.readOutput is incremental: consecutive reads never repeat output. Lossy reads report truncation and available spill files.\n- A still-running process is stopped and awaited when its owning composition tears down. With the subprocess seam that boundary is `ctx.subprocess` disposal, so a process survives an executor-only reload.',
methods: [
{
signature: 'abstract resolve(request: ShellExecRequest): ShellExecSpec',
description: 'Apply implementation-owned defaults and caps to a request before execution.',
parameters: [{ name: 'request', description: 'the caller\'s request; omitted fields get this implementation\'s defaults, capped fields are clamped.' }],
returns: 'the fully-specified spec to hand to {@link run}/{@link start}.',
returns: 'the fully-specified spec to hand to {@link execute}.',
},
{
signature: 'abstract run(spec: ShellExecSpec): Promise<ShellRunResult>',
description: 'Run preparation and the foreground command under the resolved timeout.',
signature: 'abstract execute(spec: ShellExecSpec): Promise<ShellExecution>',
description: 'Prepare and spawn the command under its resolved deadline.',
parameters: [{ name: 'spec', description: 'a resolved spec from {@link resolve}, never a raw request.' }],
returns: 'the outcome; nonzero exits, timeout kills, and abort kills resolve with a descriptive result rather than reject.',
returns: 'the prepared handle, including its result projection; preparation timeout yields an already-settled handle with no output.',
throws: ['on preparation failure or caller cancellation before process publication.'],
},
{
signature: 'abstract start(spec: ShellExecSpec): Promise<ShellProcess>',
description: 'Prepare a background process asynchronously and publish its live handle.',
parameters: [{ name: 'spec', description: 'a resolved spec from {@link resolve}, never a raw request.' }],
returns: 'the live process handle after preparation; cancellation or setup failure rejects.',
},
],
},
{
@@ -5027,7 +5032,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'JobEvent',
declaration: 'export type JobEvent = {\n readonly type: \'registered\' | \'progress\' | \'stopping\' | \'removed\';\n readonly job: JobView;\n} | {\n readonly type: \'settled\';\n readonly job: JobView;\n readonly cause: JobSettleCause;\n} | {\n readonly type: \'output\';\n readonly id: JobId;\n readonly owner?: SessionId;\n readonly total: number;\n};',
declaration: 'export type JobEvent = {\n readonly type: \'registered\' | \'progress\' | \'stopping\' | \'removed\';\n readonly job: JobView;\n} | {\n readonly type: \'settled\';\n readonly job: JobView;\n readonly cause: JobSettleCause;\n readonly awaited: boolean;\n} | {\n readonly type: \'output\';\n readonly id: JobId;\n readonly owner?: SessionId;\n readonly total: number;\n};',
},
{
name: 'JobEventFilter',
@@ -5061,6 +5066,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'JobId',
declaration: 'export type JobId = Branded<\'JobId\'>;',
},
{
name: 'JobKillRequest',
declaration: 'export interface JobKillRequest {\n readonly sessionId: SessionId;\n readonly jobId: JobId;\n}',
},
{
name: 'JobKillValue',
declaration: 'export interface JobKillValue {\n readonly outcome: \'requested\' | \'already-finished\';\n}',
},
{
name: 'JobKind',
declaration: 'export type JobKind = JobKindMap[keyof JobKindMap];',
@@ -6435,11 +6448,19 @@ export const TYPE_API: readonly TypeApiEntry[] = [
},
{
name: 'ShellExecRequest',
declaration: 'export interface ShellExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}',
declaration: 'export interface ShellExecRequest {\n command: string;\n workdir?: string | undefined;\n timeoutMs?: number | undefined;\n onExpiry?: ShellExpiryPolicy | undefined;\n stdoutMaxBytes?: number | undefined;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy?: SandboxExecutionPolicy | undefined;\n}',
},
{
name: 'ShellExecSpec',
declaration: 'export interface ShellExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}',
declaration: 'export interface ShellExecSpec {\n command: string;\n workdir: string;\n timeoutMs: number;\n onExpiry: ShellExpiryPolicy;\n stdoutMaxBytes: number;\n signal?: AbortSignal | undefined;\n stdin?: string | undefined;\n env?: Record<string, string> | undefined;\n dshEnv?: DshEnvironment | undefined;\n sandboxPolicy: SandboxExecutionPolicy | undefined;\n}',
},
{
name: 'ShellExecution',
declaration: 'export interface ShellExecution extends ShellProcess {\n result(): Promise<ShellRunResult>;\n}',
},
{
name: 'ShellExpiryPolicy',
declaration: 'export type ShellExpiryPolicy = \'kill\' | \'none\';',
},
{
name: 'ShellObservedStreams',
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more